summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/justifiedgallery.js1167
-rw-r--r--scripts/oneko.js279
2 files changed, 1446 insertions, 0 deletions
diff --git a/scripts/justifiedgallery.js b/scripts/justifiedgallery.js
new file mode 100644
index 0000000..0d8c2e1
--- /dev/null
+++ b/scripts/justifiedgallery.js
@@ -0,0 +1,1167 @@
+(function () {
+
+ const CONFIG = {
+ folders: ['images/', 'images2/'], //many folders
+ imageExtensions: /\.(jpe?g|png|gif|webp|avif|bmp)$/i,
+ thumbFolder: 'thumbs/', //mirrors `folders`, generated by generate-thumbs.sh
+ manifest: 'manifest.json', //built by generate-thumbs.sh: {folder,file,width,height}[]
+ rowHeight: 80, //target row height (px)
+ margins: 2, //gap between images (px)
+ maxRowHeight: '150%', //cap on stretched row height
+ lastRow: 'nojustify' //how to handle the final incomplete row
+ };
+
+ const galleryEl = document.getElementById('gallery');
+ const clickfunction = document.getElementById('clickfunction');
+ const clickfunctionImg = document.getElementById('clickfunction-img');
+ const clickfunctionDesc = document.getElementById('clickfunction-desc');
+ const clickfunctionClose = document.getElementById('clickfunction-close');
+
+ function openClickfunction(src, description) {
+ clickfunctionImg.src = src;
+ clickfunctionDesc.textContent = description || '';
+ clickfunction.showModal();
+ }
+
+ clickfunctionClose.addEventListener('click', () => clickfunction.close());
+ clickfunction.addEventListener('click', (e) => {
+ if (e.target === clickfunction) clickfunction.close();
+ });
+
+
+ //Descriptions are fetched lazily (only when an entry is clicked), not
+ //up front for every image, so the gallery doesn't wait on N extra
+ //requests before it can render.
+ const descriptionCache = new Map();
+ async function fetchDescription(folder, imageFile) {
+ const descFile = folder + imageFile.replace(CONFIG.imageExtensions, '.txt');
+ if (descriptionCache.has(descFile)) return descriptionCache.get(descFile);
+ let description = '';
+ try {
+ const res = await fetch(descFile);
+ if (res.ok) description = (await res.text()).trim();
+ } catch (e) {
+ //ignore all errors
+ }
+ descriptionCache.set(descFile, description);
+ return description;
+ }
+
+ // manifest.json is built by generate-thumbs.sh: real dims mean
+ // justifiedGallery() never has to download full images just to measure
+ // them, and the grid loads actual small thumbnails instead of full-res
+ // originals. Browser handles lazy-loading + caching natively.
+ async function loadGallery() {
+ const res = await fetch(CONFIG.manifest);
+ const items = await res.json();
+
+ items.forEach(({ folder, file, width, height }) => {
+ const src = folder + file;
+ const thumbSrc = CONFIG.thumbFolder + folder + file.replace(CONFIG.imageExtensions, '.webp');
+ const label = file.replace(CONFIG.imageExtensions, '');
+
+ const entry = document.createElement('div');
+ const img = document.createElement('img');
+ img.alt = label; // used by the library for hover captions
+ img.decoding = 'async';
+ img.loading = 'lazy';
+ img.width = width;
+ img.height = height;
+ img.src = thumbSrc;
+
+ entry.appendChild(img);
+ entry.addEventListener('click', async () => {
+ const description = await fetchDescription(folder, file);
+ openClickfunction(src, description || label);
+ });
+
+ galleryEl.appendChild(entry);
+ });
+
+ justifiedGallery('#gallery', {
+ selector: 'div',
+ rowHeight: CONFIG.rowHeight,
+ margins: CONFIG.margins,
+ maxRowHeight: CONFIG.maxRowHeight,
+ lastRow: CONFIG.lastRow
+ });
+ }
+
+ loadGallery();
+
+})();
+
+
+/*!
+ * justifiedGallery - v3.8.1 (Vanilla JS conversion by cfconsultancy, trimmed)
+ * https://miromannino.github.io/Justified-Gallery/
+ * Copyright (c) 2020 Miro Mannino
+ * Licensed under the MIT license.
+ * */
+const justifiedGallery = (function () {
+
+ const utils = {
+ isFunction(obj) {
+ return typeof obj === 'function';
+ },
+ isString(obj) {
+ return typeof obj === 'string';
+ },
+ isObject(obj) {
+ return typeof obj === 'object' && obj !== null;
+ },
+ isBoolean(obj) {
+ return typeof obj === 'boolean';
+ },
+ extend(...args) {
+ const result = {};
+ args.forEach(obj => {
+ if (obj) {
+ Object.keys(obj).forEach(key => {
+ result[key] = obj[key];
+ });
+ }
+ });
+ return result;
+ },
+ addClass(el, className) {
+ el.classList.add(className);
+ },
+ removeClass(el, className) {
+ el.classList.remove(className);
+ },
+ hasClass(el, className) {
+ return el.classList.contains(className);
+ },
+ fadeIn(el, duration, callback) {
+ el.style.opacity = 0;
+ el.style.display = 'block';
+ const start = performance.now();
+ const animate = (time) => {
+ const progress = Math.min((time - start) / duration, 1);
+ el.style.opacity = progress;
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ } else if (callback) {
+ callback();
+ }
+ };
+ requestAnimationFrame(animate);
+ },
+ fadeTo(el, duration, opacity, callback) {
+ const startOpacity = parseFloat(getComputedStyle(el).opacity) || 0;
+ const start = performance.now();
+ const animate = (time) => {
+ const progress = Math.min((time - start) / duration, 1);
+ el.style.opacity = startOpacity + (opacity - startOpacity) * progress;
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ } else if (callback) {
+ callback();
+ }
+ };
+ requestAnimationFrame(animate);
+ },
+ trigger(el, eventName, detail) {
+ const event = new CustomEvent(eventName, { detail: detail || {} });
+ el.dispatchEvent(event);
+ }
+ };
+
+ class JustifiedGallery {
+ constructor(gallery, settings) {
+ this.gallery = gallery;
+ this.settings = utils.extend({}, this.defaults, settings);
+ this.checkSettings();
+
+ this.imgAnalyzerTimeout = null;
+ this.entries = null;
+ this.buildingRow = {
+ entriesBuff: [],
+ width: 0,
+ height: 0,
+ aspectRatio: 0
+ };
+ this.lastFetchedEntry = null;
+ this.lastAnalyzedIndex = -1;
+ this.yield = {
+ every: 2,
+ flushed: 0
+ };
+ this.border = this.settings.border >= 0 ? this.settings.border : this.settings.margins;
+ this.maxRowHeight = this.retrieveMaxRowHeight();
+ this.suffixRanges = this.retrieveSuffixRanges();
+ this.offY = this.border;
+ this.rows = 0;
+ this.spinner = {
+ phase: 0,
+ timeSlot: 150,
+ el: this.createSpinnerElement(),
+ intervalId: null
+ };
+ this.scrollBarOn = false;
+ this.resizeObserver = null;
+ this.galleryWidth = this.gallery.offsetWidth;
+ this.galleryPrevStaticHeight = 0;
+
+ this.elementData = new WeakMap();
+ }
+
+ createSpinnerElement() {
+ const spinner = document.createElement('div');
+ spinner.className = 'jg-spinner';
+ for (let i = 0; i < 3; i++) {
+ spinner.appendChild(document.createElement('span'));
+ }
+ return spinner;
+ }
+
+ setData(el, key, value) {
+ let data = this.elementData.get(el);
+ if (!data) {
+ data = {};
+ this.elementData.set(el, data);
+ }
+ data[key] = value;
+ }
+
+ getData(el, key) {
+ const data = this.elementData.get(el);
+ return data ? data[key] : undefined;
+ }
+
+ removeData(el, key) {
+ const data = this.elementData.get(el);
+ if (data && key in data) {
+ delete data[key];
+ }
+ }
+
+ getSuffix(width, height) {
+ const longestSide = Math.max(width, height);
+ for (let i = 0; i < this.suffixRanges.length; i++) {
+ if (longestSide <= this.suffixRanges[i]) {
+ return this.settings.sizeRangeSuffixes[this.suffixRanges[i]];
+ }
+ }
+ return this.settings.sizeRangeSuffixes[this.suffixRanges[this.suffixRanges.length - 1]];
+ }
+
+ removeSuffix(str, suffix) {
+ return str.substring(0, str.length - suffix.length);
+ }
+
+ endsWith(str, suffix) {
+ return str.indexOf(suffix, str.length - suffix.length) !== -1;
+ }
+
+ getUsedSuffix(str) {
+ for (let si in this.settings.sizeRangeSuffixes) {
+ if (this.settings.sizeRangeSuffixes.hasOwnProperty(si)) {
+ if (this.settings.sizeRangeSuffixes[si].length === 0) continue;
+ if (this.endsWith(str, this.settings.sizeRangeSuffixes[si])) {
+ return this.settings.sizeRangeSuffixes[si];
+ }
+ }
+ }
+ return '';
+ }
+
+ newSrc(imageSrc, imgWidth, imgHeight, image) {
+ let newImageSrc;
+ if (this.settings.thumbnailPath) {
+ newImageSrc = this.settings.thumbnailPath(imageSrc, imgWidth, imgHeight, image);
+ } else {
+ const matchRes = imageSrc.match(this.settings.extension);
+ const ext = matchRes !== null ? matchRes[0] : '';
+ newImageSrc = imageSrc.replace(this.settings.extension, '');
+ newImageSrc = this.removeSuffix(newImageSrc, this.getUsedSuffix(newImageSrc));
+ newImageSrc += this.getSuffix(imgWidth, imgHeight) + ext;
+ }
+ return newImageSrc;
+ }
+
+ showImg(entry, callback) {
+ if (this.settings.cssAnimation) {
+ utils.addClass(entry, 'jg-entry-visible');
+ if (callback) callback();
+ } else {
+ utils.fadeTo(entry, this.settings.imagesAnimationDuration, 1.0, callback);
+ const img = this.imgFromEntry(entry);
+ if (img) {
+ utils.fadeTo(img, this.settings.imagesAnimationDuration, 1.0);
+ }
+ }
+ }
+
+ extractImgSrcFromImage(image) {
+ let imageSrc = image.getAttribute('data-safe-src');
+ let imageSrcLoc = 'data-safe-src';
+ if (!imageSrc) {
+ imageSrc = image.getAttribute('src');
+ imageSrcLoc = 'src';
+ }
+ this.setData(image, 'jg.originalSrc', imageSrc);
+ this.setData(image, 'jg.src', imageSrc);
+ this.setData(image, 'jg.originalSrcLoc', imageSrcLoc);
+ return imageSrc;
+ }
+
+ imgFromEntry(entry) {
+ const selectors = this.settings.imgSelector.split(',').map(s => s.trim());
+ for (let selector of selectors) {
+ const cleanSelector = selector.replace(/^>\s*/, '');
+ const elements = Array.from(entry.querySelectorAll(cleanSelector));
+ const img = selector.startsWith('>')
+ ? elements.find(el => el.parentNode === entry)
+ : elements[0];
+ if (img) return img;
+ }
+ return null;
+ }
+
+ captionFromEntry(entry) {
+ const captions = Array.from(entry.querySelectorAll('.jg-caption'));
+ return captions.find(el => el.parentNode === entry) || null;
+ }
+
+ displayEntry(entry, x, y, imgWidth, imgHeight, rowHeight) {
+ entry.style.width = imgWidth + 'px';
+ entry.style.height = rowHeight + 'px';
+ entry.style.top = y + 'px';
+ entry.style.left = x + 'px';
+
+ const image = this.imgFromEntry(entry);
+ if (image !== null) {
+ image.style.width = imgWidth + 'px';
+ image.style.height = imgHeight + 'px';
+ image.style.marginLeft = (-imgWidth / 2) + 'px';
+ image.style.marginTop = (-imgHeight / 2) + 'px';
+
+ let imageSrc = this.getData(image, 'jg.src');
+ if (imageSrc) {
+ imageSrc = this.newSrc(imageSrc, imgWidth, imgHeight, image);
+
+ image.addEventListener('error', () => {
+ this.resetImgSrc(image);
+ }, { once: true });
+
+ const loadNewImage = () => {
+ image.setAttribute('src', imageSrc);
+ };
+
+ if (this.getData(entry, 'jg.loaded') === 'skipped' && imageSrc) {
+ this.onImageEvent(imageSrc, () => {
+ this.showImg(entry, loadNewImage);
+ this.setData(entry, 'jg.loaded', true);
+ });
+ } else {
+ this.showImg(entry, loadNewImage);
+ }
+ }
+ } else {
+ this.showImg(entry);
+ }
+
+ this.displayEntryCaption(entry);
+ }
+
+ displayEntryCaption(entry) {
+ const image = this.imgFromEntry(entry);
+ if (image !== null && this.settings.captions) {
+ let imgCaption = this.captionFromEntry(entry);
+
+ if (imgCaption === null) {
+ let caption = image.getAttribute('alt');
+ if (!this.isValidCaption(caption)) {
+ caption = entry.getAttribute('title');
+ }
+ if (this.isValidCaption(caption)) {
+ imgCaption = document.createElement('div');
+ imgCaption.className = 'jg-caption';
+ imgCaption.textContent = caption;
+ entry.appendChild(imgCaption);
+ this.setData(entry, 'jg.createdCaption', true);
+ }
+ }
+
+ if (imgCaption !== null) {
+ if (!this.settings.cssAnimation) {
+ imgCaption.style.opacity = this.settings.captionSettings.nonVisibleOpacity;
+ }
+ this.addCaptionEventsHandlers(entry);
+ }
+ } else {
+ this.removeCaptionEventsHandlers(entry);
+ }
+ }
+
+ isValidCaption(caption) {
+ return caption && caption.length > 0;
+ }
+
+ onEntryMouseEnterForCaption(event) {
+ const caption = this.captionFromEntry(event.currentTarget);
+ if (this.settings.cssAnimation) {
+ utils.addClass(caption, 'jg-caption-visible');
+ utils.removeClass(caption, 'jg-caption-hidden');
+ } else {
+ utils.fadeTo(caption, this.settings.captionSettings.animationDuration,
+ this.settings.captionSettings.visibleOpacity);
+ }
+ }
+
+ onEntryMouseLeaveForCaption(event) {
+ const caption = this.captionFromEntry(event.currentTarget);
+ if (this.settings.cssAnimation) {
+ utils.removeClass(caption, 'jg-caption-visible');
+ utils.removeClass(caption, 'jg-caption-hidden');
+ } else {
+ utils.fadeTo(caption, this.settings.captionSettings.animationDuration,
+ this.settings.captionSettings.nonVisibleOpacity);
+ }
+ }
+
+ addCaptionEventsHandlers(entry) {
+ let captionMouseEvents = this.getData(entry, 'jg.captionMouseEvents');
+ if (typeof captionMouseEvents === 'undefined') {
+ captionMouseEvents = {
+ mouseenter: this.onEntryMouseEnterForCaption.bind(this),
+ mouseleave: this.onEntryMouseLeaveForCaption.bind(this)
+ };
+ entry.addEventListener('mouseenter', captionMouseEvents.mouseenter);
+ entry.addEventListener('mouseleave', captionMouseEvents.mouseleave);
+ this.setData(entry, 'jg.captionMouseEvents', captionMouseEvents);
+ }
+ }
+
+ removeCaptionEventsHandlers(entry) {
+ const captionMouseEvents = this.getData(entry, 'jg.captionMouseEvents');
+ if (typeof captionMouseEvents !== 'undefined') {
+ entry.removeEventListener('mouseenter', captionMouseEvents.mouseenter);
+ entry.removeEventListener('mouseleave', captionMouseEvents.mouseleave);
+ this.removeData(entry, 'jg.captionMouseEvents');
+ }
+ }
+
+ clearBuildingRow() {
+ this.buildingRow.entriesBuff = [];
+ this.buildingRow.aspectRatio = 0;
+ this.buildingRow.width = 0;
+ }
+
+ prepareBuildingRow(isLastRow, hiddenRow) {
+ let i, entry, imgAspectRatio, newImgW, newImgH, justify = true;
+ let minHeight = 0;
+
+ const availableWidth = this.galleryWidth - 2 * this.border -
+ ((this.buildingRow.entriesBuff.length - 1) * this.settings.margins);
+ const rowHeight = availableWidth / this.buildingRow.aspectRatio;
+ let defaultRowHeight = this.settings.rowHeight;
+ const justifiable = this.buildingRow.width / availableWidth > this.settings.justifyThreshold;
+
+ if (hiddenRow || (isLastRow && this.settings.lastRow === 'hide' && !justifiable)) {
+ for (i = 0; i < this.buildingRow.entriesBuff.length; i++) {
+ entry = this.buildingRow.entriesBuff[i];
+ if (this.settings.cssAnimation) {
+ utils.removeClass(entry, 'jg-entry-visible');
+ } else {
+ utils.fadeTo(entry, 0, 0.1);
+ const img = entry.querySelector('> img, > a > img');
+ if (img) utils.fadeTo(img, 0, 0);
+ }
+ }
+ return -1;
+ }
+
+ if (isLastRow && !justifiable && this.settings.lastRow !== 'justify' && this.settings.lastRow !== 'hide') {
+ justify = false;
+ if (this.rows > 0) {
+ defaultRowHeight = (this.offY - this.border - this.settings.margins * this.rows) / this.rows;
+ justify = defaultRowHeight * this.buildingRow.aspectRatio / availableWidth > this.settings.justifyThreshold;
+ }
+ }
+
+ let availWidth = availableWidth;
+ for (i = 0; i < this.buildingRow.entriesBuff.length; i++) {
+ entry = this.buildingRow.entriesBuff[i];
+ imgAspectRatio = this.getData(entry, 'jg.width') / this.getData(entry, 'jg.height');
+
+ if (justify) {
+ newImgW = (i === this.buildingRow.entriesBuff.length - 1) ? availWidth : rowHeight * imgAspectRatio;
+ newImgH = rowHeight;
+ } else {
+ newImgW = defaultRowHeight * imgAspectRatio;
+ newImgH = defaultRowHeight;
+ }
+
+ availWidth -= Math.round(newImgW);
+ this.setData(entry, 'jg.jwidth', Math.round(newImgW));
+ this.setData(entry, 'jg.jheight', Math.ceil(newImgH));
+ if (i === 0 || minHeight > newImgH) minHeight = newImgH;
+ }
+
+ this.buildingRow.height = minHeight;
+ return justify;
+ }
+
+ flushRow(isLastRow, hiddenRow) {
+ const settings = this.settings;
+ let entry, buildingRowRes, offX = this.border, i;
+
+ buildingRowRes = this.prepareBuildingRow(isLastRow, hiddenRow);
+ if (hiddenRow || (isLastRow && settings.lastRow === 'hide' && buildingRowRes === -1)) {
+ this.clearBuildingRow();
+ return;
+ }
+
+ if (this.maxRowHeight) {
+ if (this.maxRowHeight < this.buildingRow.height) {
+ this.buildingRow.height = this.maxRowHeight;
+ }
+ }
+
+ if (isLastRow && (settings.lastRow === 'center' || settings.lastRow === 'right')) {
+ let availableWidth = this.galleryWidth - 2 * this.border -
+ (this.buildingRow.entriesBuff.length - 1) * settings.margins;
+
+ for (i = 0; i < this.buildingRow.entriesBuff.length; i++) {
+ entry = this.buildingRow.entriesBuff[i];
+ availableWidth -= this.getData(entry, 'jg.jwidth');
+ }
+
+ if (settings.lastRow === 'center') {
+ offX += Math.round(availableWidth / 2);
+ } else if (settings.lastRow === 'right') {
+ offX += availableWidth;
+ }
+ }
+
+ const lastEntryIdx = this.buildingRow.entriesBuff.length - 1;
+ for (i = 0; i <= lastEntryIdx; i++) {
+ entry = this.buildingRow.entriesBuff[this.settings.rtl ? lastEntryIdx - i : i];
+ this.displayEntry(entry, offX, this.offY,
+ this.getData(entry, 'jg.jwidth'),
+ this.getData(entry, 'jg.jheight'),
+ this.buildingRow.height);
+ offX += this.getData(entry, 'jg.jwidth') + settings.margins;
+ }
+
+ this.galleryHeightToSet = this.offY + this.buildingRow.height + this.border;
+ this.setGalleryTempHeight(this.galleryHeightToSet + this.getSpinnerHeight());
+
+ if (!isLastRow || (this.buildingRow.height <= settings.rowHeight && buildingRowRes)) {
+ this.offY += this.buildingRow.height + settings.margins;
+ this.rows += 1;
+ this.clearBuildingRow();
+ this.settings.triggerEvent.call(this, 'jg.rowflush');
+ }
+ }
+
+ rememberGalleryHeight() {
+ this.galleryPrevStaticHeight = this.gallery.offsetHeight;
+ this.gallery.style.height = this.galleryPrevStaticHeight + 'px';
+ }
+
+ setGalleryTempHeight(height) {
+ this.galleryPrevStaticHeight = Math.max(height, this.galleryPrevStaticHeight);
+ this.gallery.style.height = this.galleryPrevStaticHeight + 'px';
+ }
+
+ setGalleryFinalHeight(height) {
+ this.galleryPrevStaticHeight = height;
+ this.gallery.style.height = height + 'px';
+ }
+
+
+ checkWidth() {
+ if (this.resizeObserver) return;
+ this.resizeObserver = new ResizeObserver(() => {
+ const isVisible = this.gallery.offsetParent !== null;
+ if (!isVisible) return;
+
+ const galleryWidth = parseFloat(this.gallery.offsetWidth);
+ if (Math.abs(galleryWidth - this.galleryWidth) > this.settings.refreshSensitivity) {
+ this.galleryWidth = galleryWidth;
+ this.rewind();
+ this.rememberGalleryHeight();
+ this.startImgAnalyzer(true);
+ }
+ });
+ this.resizeObserver.observe(this.gallery);
+ }
+
+ isSpinnerActive() {
+ return this.spinner.intervalId !== null;
+ }
+
+ getSpinnerHeight() {
+ return this.spinner.el.offsetHeight || 0;
+ }
+
+ stopLoadingSpinnerAnimation() {
+ clearInterval(this.spinner.intervalId);
+ this.spinner.intervalId = null;
+ this.setGalleryTempHeight(this.gallery.offsetHeight - this.getSpinnerHeight());
+ if (this.spinner.el.parentNode) {
+ this.spinner.el.parentNode.removeChild(this.spinner.el);
+ }
+ }
+
+ startLoadingSpinnerAnimation() {
+ const spinnerContext = this.spinner;
+ const spinnerPoints = spinnerContext.el.querySelectorAll('span');
+ clearInterval(spinnerContext.intervalId);
+ this.gallery.appendChild(spinnerContext.el);
+ this.setGalleryTempHeight(this.offY + this.buildingRow.height + this.getSpinnerHeight());
+ spinnerContext.intervalId = setInterval(() => {
+ if (spinnerContext.phase < spinnerPoints.length) {
+ utils.fadeTo(spinnerPoints[spinnerContext.phase], spinnerContext.timeSlot, 1);
+ } else {
+ utils.fadeTo(spinnerPoints[spinnerContext.phase - spinnerPoints.length], spinnerContext.timeSlot, 0);
+ }
+ spinnerContext.phase = (spinnerContext.phase + 1) % (spinnerPoints.length * 2);
+ }, spinnerContext.timeSlot);
+ }
+
+ rewind() {
+ this.lastFetchedEntry = null;
+ this.lastAnalyzedIndex = -1;
+ this.offY = this.border;
+ this.rows = 0;
+ this.clearBuildingRow();
+ }
+
+ getSelectorWithoutSpinner() {
+ return this.settings.selector + ', div:not(.jg-spinner)';
+ }
+
+ getAllEntries() {
+ const selectorParts = this.settings.selector.split(',').map(s => s.trim());
+ const allChildren = Array.from(this.gallery.children);
+ return allChildren.filter(child => {
+ if (child.classList && child.classList.contains('jg-spinner')) {
+ return false;
+ }
+ return selectorParts.some(selector => {
+ try {
+ return child.matches(selector);
+ } catch (e) {
+ return false;
+ }
+ });
+ });
+ }
+
+ updateEntries(norewind) {
+ let newEntries;
+
+ if (norewind && this.lastFetchedEntry != null) {
+ const selector = this.getSelectorWithoutSpinner();
+ let sibling = this.lastFetchedEntry.nextElementSibling;
+ newEntries = [];
+ while (sibling) {
+ if (sibling.matches(selector)) {
+ newEntries.push(sibling);
+ }
+ sibling = sibling.nextElementSibling;
+ }
+ } else {
+ this.entries = [];
+ newEntries = this.getAllEntries();
+ }
+
+ if (newEntries.length > 0) {
+ if (utils.isFunction(this.settings.sort)) {
+ newEntries = this.sortArray(newEntries);
+ } else if (this.settings.randomize) {
+ newEntries = this.shuffleArray(newEntries);
+ }
+ this.lastFetchedEntry = newEntries[newEntries.length - 1];
+
+ if (this.settings.filter) {
+ newEntries = this.filterArray(newEntries);
+ } else {
+ this.resetFilters(newEntries);
+ }
+ }
+
+ this.entries = this.entries.concat(newEntries);
+ return true;
+ }
+
+ insertToGallery(entries) {
+ entries.forEach(entry => {
+ this.gallery.appendChild(entry);
+ });
+ }
+
+ shuffleArray(a) {
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ this.insertToGallery(a);
+ return a;
+ }
+
+ sortArray(a) {
+ a.sort(this.settings.sort);
+ this.insertToGallery(a);
+ return a;
+ }
+
+ resetFilters(a) {
+ a.forEach(entry => utils.removeClass(entry, 'jg-filtered'));
+ }
+
+ filterArray(a) {
+ const settings = this.settings;
+ if (utils.isString(settings.filter)) {
+ return a.filter(el => {
+ if (el.matches(settings.filter)) {
+ utils.removeClass(el, 'jg-filtered');
+ return true;
+ } else {
+ utils.addClass(el, 'jg-filtered');
+ utils.removeClass(el, 'jg-visible');
+ return false;
+ }
+ });
+ } else if (utils.isFunction(settings.filter)) {
+ const filteredArr = a.filter(settings.filter);
+ a.forEach(entry => {
+ if (filteredArr.indexOf(entry) === -1) {
+ utils.addClass(entry, 'jg-filtered');
+ utils.removeClass(entry, 'jg-visible');
+ } else {
+ utils.removeClass(entry, 'jg-filtered');
+ }
+ });
+ return filteredArr;
+ }
+ }
+
+ resetImgSrc(img) {
+ if (this.getData(img, 'jg.originalSrcLoc') === 'src') {
+ img.setAttribute('src', this.getData(img, 'jg.originalSrc'));
+ } else {
+ img.setAttribute('src', '');
+ }
+ }
+
+ destroy() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ }
+ this.stopImgAnalyzerStarter();
+
+ this.getAllEntries().forEach(entry => {
+ entry.style.width = '';
+ entry.style.height = '';
+ entry.style.top = '';
+ entry.style.left = '';
+ this.removeData(entry, 'jg.loaded');
+ utils.removeClass(entry, 'jg-entry');
+ utils.removeClass(entry, 'jg-filtered');
+ utils.removeClass(entry, 'jg-entry-visible');
+
+ const img = this.imgFromEntry(entry);
+ if (img) {
+ img.style.width = '';
+ img.style.height = '';
+ img.style.marginLeft = '';
+ img.style.marginTop = '';
+ this.resetImgSrc(img);
+ this.removeData(img, 'jg.originalSrc');
+ this.removeData(img, 'jg.originalSrcLoc');
+ this.removeData(img, 'jg.src');
+ }
+
+ this.removeCaptionEventsHandlers(entry);
+ const caption = this.captionFromEntry(entry);
+ if (this.getData(entry, 'jg.createdCaption')) {
+ this.removeData(entry, 'jg.createdCaption');
+ if (caption !== null) caption.remove();
+ } else {
+ if (caption !== null) caption.style.opacity = 1;
+ }
+ });
+
+ this.gallery.style.height = '';
+ utils.removeClass(this.gallery, 'justified-gallery');
+ this.elementData.delete(this.gallery);
+ this.settings.triggerEvent.call(this, 'jg.destroy');
+ }
+
+ analyzeImages(isForResize) {
+ for (let i = this.lastAnalyzedIndex + 1; i < this.entries.length; i++) {
+ const entry = this.entries[i];
+ const loaded = this.getData(entry, 'jg.loaded');
+
+ if (loaded === true || loaded === 'skipped') {
+ const availableWidth = this.galleryWidth - 2 * this.border -
+ ((this.buildingRow.entriesBuff.length - 1) * this.settings.margins);
+ const imgAspectRatio = this.getData(entry, 'jg.width') / this.getData(entry, 'jg.height');
+
+ this.buildingRow.entriesBuff.push(entry);
+ this.buildingRow.aspectRatio += imgAspectRatio;
+ this.buildingRow.width += imgAspectRatio * this.settings.rowHeight;
+ this.lastAnalyzedIndex = i;
+
+ if (availableWidth / (this.buildingRow.aspectRatio + imgAspectRatio) < this.settings.rowHeight) {
+ this.flushRow(false, this.settings.maxRowsCount > 0 && this.rows === this.settings.maxRowsCount);
+
+ if (++this.yield.flushed >= this.yield.every) {
+ this.startImgAnalyzer(isForResize);
+ return;
+ }
+ }
+ } else if (loaded !== 'error') {
+ return;
+ }
+ }
+
+ if (this.buildingRow.entriesBuff.length > 0) {
+ this.flushRow(true, this.settings.maxRowsCount > 0 && this.rows === this.settings.maxRowsCount);
+ }
+
+ if (this.isSpinnerActive()) {
+ this.stopLoadingSpinnerAnimation();
+ }
+
+ this.stopImgAnalyzerStarter();
+ this.setGalleryFinalHeight(this.galleryHeightToSet);
+ this.settings.triggerEvent.call(this, isForResize ? 'jg.resize' : 'jg.complete');
+ }
+
+ stopImgAnalyzerStarter() {
+ this.yield.flushed = 0;
+ if (this.imgAnalyzerTimeout !== null) {
+ clearTimeout(this.imgAnalyzerTimeout);
+ this.imgAnalyzerTimeout = null;
+ }
+ }
+
+ startImgAnalyzer(isForResize) {
+ this.stopImgAnalyzerStarter();
+ this.imgAnalyzerTimeout = setTimeout(() => {
+ this.analyzeImages(isForResize);
+ }, 1);
+ }
+
+ onImageEvent(imageSrc, onLoad, onError) {
+ if (!onLoad && !onError) return;
+
+ const memImage = new Image();
+ let loadHandler, errorHandler;
+
+ if (onLoad) {
+ loadHandler = function () {
+ memImage.removeEventListener('load', loadHandler);
+ if (errorHandler) memImage.removeEventListener('error', errorHandler);
+ onLoad(memImage);
+ };
+ memImage.addEventListener('load', loadHandler);
+ }
+
+ if (onError) {
+ errorHandler = function () {
+ if (loadHandler) memImage.removeEventListener('load', loadHandler);
+ memImage.removeEventListener('error', errorHandler);
+ onError(memImage);
+ };
+ memImage.addEventListener('error', errorHandler);
+ }
+
+ memImage.src = imageSrc;
+ }
+
+ init() {
+ let imagesToLoad = false;
+ let skippedImages = false;
+
+ this.entries.forEach((entry) => {
+ const image = this.imgFromEntry(entry);
+
+ utils.addClass(entry, 'jg-entry');
+
+ if (this.getData(entry, 'jg.loaded') !== true && this.getData(entry, 'jg.loaded') !== 'skipped') {
+ if (this.settings.rel !== null) {
+ entry.setAttribute('rel', this.settings.rel);
+ }
+ if (this.settings.target !== null) {
+ entry.setAttribute('target', this.settings.target);
+ }
+
+ if (image !== null) {
+ const imageSrc = this.extractImgSrcFromImage(image);
+
+ if (this.settings.waitThumbnailsLoad === false || !imageSrc) {
+ let width = parseFloat(image.getAttribute('width'));
+ let height = parseFloat(image.getAttribute('height'));
+
+ if (image.tagName.toLowerCase() === 'svg') {
+ const bbox = image.getBBox();
+ width = parseFloat(bbox.width);
+ height = parseFloat(bbox.height);
+ }
+
+ if (!isNaN(width) && !isNaN(height)) {
+ this.setData(entry, 'jg.width', width);
+ this.setData(entry, 'jg.height', height);
+ this.setData(entry, 'jg.loaded', 'skipped');
+ skippedImages = true;
+ this.startImgAnalyzer(false);
+ return;
+ }
+ }
+
+ this.setData(entry, 'jg.loaded', false);
+ imagesToLoad = true;
+
+ if (!this.isSpinnerActive()) {
+ this.startLoadingSpinnerAnimation();
+ }
+
+ this.onImageEvent(imageSrc,
+ (loadImg) => {
+ this.setData(entry, 'jg.width', loadImg.width);
+ this.setData(entry, 'jg.height', loadImg.height);
+ this.setData(entry, 'jg.loaded', true);
+ this.startImgAnalyzer(false);
+ },
+ () => {
+ this.setData(entry, 'jg.loaded', 'error');
+ this.startImgAnalyzer(false);
+ }
+ );
+ } else {
+ this.setData(entry, 'jg.loaded', true);
+ this.setData(entry, 'jg.width',
+ entry.offsetWidth || parseFloat(getComputedStyle(entry).width) || 1);
+ this.setData(entry, 'jg.height',
+ entry.offsetHeight || parseFloat(getComputedStyle(entry).height) || 1);
+ }
+ }
+ });
+
+ if (!imagesToLoad && !skippedImages) {
+ this.startImgAnalyzer(false);
+ }
+ this.checkWidth();
+ }
+
+ checkOrConvertNumber(settingContainer, settingName) {
+ if (typeof settingContainer[settingName] === 'string') {
+ settingContainer[settingName] = parseFloat(settingContainer[settingName]);
+ }
+ if (typeof settingContainer[settingName] === 'number') {
+ if (isNaN(settingContainer[settingName])) {
+ throw 'invalid number for ' + settingName;
+ }
+ } else {
+ throw settingName + ' must be a number';
+ }
+ }
+
+ checkSizeRangesSuffixes() {
+ if (!utils.isObject(this.settings.sizeRangeSuffixes)) {
+ throw 'sizeRangeSuffixes must be defined and must be an object';
+ }
+ const suffixRanges = Object.keys(this.settings.sizeRangeSuffixes);
+ const newSizeRngSuffixes = { 0: '' };
+ suffixRanges.forEach(rangeIdx => {
+ if (typeof rangeIdx === 'string') {
+ try {
+ const numIdx = parseInt(rangeIdx.replace(/^[a-z]+/, ''), 10);
+ newSizeRngSuffixes[numIdx] = this.settings.sizeRangeSuffixes[rangeIdx];
+ } catch (e) {
+ throw 'sizeRangeSuffixes keys must contains correct numbers (' + e + ')';
+ }
+ } else {
+ newSizeRngSuffixes[rangeIdx] = this.settings.sizeRangeSuffixes[rangeIdx];
+ }
+ });
+ this.settings.sizeRangeSuffixes = newSizeRngSuffixes;
+ }
+
+ retrieveMaxRowHeight() {
+ let newMaxRowHeight = null;
+ const rowHeight = this.settings.rowHeight;
+
+ if (typeof this.settings.maxRowHeight === 'string') {
+ if (this.settings.maxRowHeight.match(/^[0-9]+%$/)) {
+ newMaxRowHeight = rowHeight * parseFloat(this.settings.maxRowHeight.match(/^([0-9]+)%$/)[1]) / 100;
+ } else {
+ newMaxRowHeight = parseFloat(this.settings.maxRowHeight);
+ }
+ } else if (typeof this.settings.maxRowHeight === 'number') {
+ newMaxRowHeight = this.settings.maxRowHeight;
+ } else if (this.settings.maxRowHeight === false || this.settings.maxRowHeight == null) {
+ return null;
+ } else {
+ throw 'maxRowHeight must be a number or a percentage';
+ }
+
+ if (isNaN(newMaxRowHeight)) {
+ throw 'invalid number for maxRowHeight';
+ }
+ if (newMaxRowHeight < rowHeight) {
+ newMaxRowHeight = rowHeight;
+ }
+ return newMaxRowHeight;
+ }
+
+ checkSettings() {
+ this.checkSizeRangesSuffixes();
+ this.checkOrConvertNumber(this.settings, 'rowHeight');
+ this.checkOrConvertNumber(this.settings, 'margins');
+ this.checkOrConvertNumber(this.settings, 'border');
+ this.checkOrConvertNumber(this.settings, 'maxRowsCount');
+
+ const lastRowModes = ['justify', 'nojustify', 'left', 'center', 'right', 'hide'];
+ if (lastRowModes.indexOf(this.settings.lastRow) === -1) {
+ throw 'lastRow must be one of: ' + lastRowModes.join(', ');
+ }
+
+ this.checkOrConvertNumber(this.settings, 'justifyThreshold');
+ if (this.settings.justifyThreshold < 0 || this.settings.justifyThreshold > 1) {
+ throw 'justifyThreshold must be in the interval [0,1]';
+ }
+
+ if (!utils.isBoolean(this.settings.cssAnimation)) {
+ throw 'cssAnimation must be a boolean';
+ }
+ if (!utils.isBoolean(this.settings.captions)) {
+ throw 'captions must be a boolean';
+ }
+
+ this.checkOrConvertNumber(this.settings.captionSettings, 'animationDuration');
+ this.checkOrConvertNumber(this.settings.captionSettings, 'visibleOpacity');
+ if (this.settings.captionSettings.visibleOpacity < 0 ||
+ this.settings.captionSettings.visibleOpacity > 1) {
+ throw 'captionSettings.visibleOpacity must be in the interval [0, 1]';
+ }
+
+ this.checkOrConvertNumber(this.settings.captionSettings, 'nonVisibleOpacity');
+ if (this.settings.captionSettings.nonVisibleOpacity < 0 ||
+ this.settings.captionSettings.nonVisibleOpacity > 1) {
+ throw 'captionSettings.nonVisibleOpacity must be in the interval [0, 1]';
+ }
+
+ this.checkOrConvertNumber(this.settings, 'imagesAnimationDuration');
+ this.checkOrConvertNumber(this.settings, 'refreshTime');
+ this.checkOrConvertNumber(this.settings, 'refreshSensitivity');
+
+ if (!utils.isBoolean(this.settings.randomize)) {
+ throw 'randomize must be a boolean';
+ }
+ if (!utils.isString(this.settings.selector)) {
+ throw 'selector must be a string';
+ }
+ if (this.settings.sort !== false && !utils.isFunction(this.settings.sort)) {
+ throw 'sort must be false or a comparison function';
+ }
+ if (this.settings.filter !== false &&
+ !utils.isFunction(this.settings.filter) &&
+ !utils.isString(this.settings.filter)) {
+ throw 'filter must be false, a string or a filter function';
+ }
+ }
+
+ retrieveSuffixRanges() {
+ const suffixRanges = Object.keys(this.settings.sizeRangeSuffixes)
+ .map(key => parseInt(key, 10));
+ suffixRanges.sort((a, b) => a - b);
+ return suffixRanges;
+ }
+
+ updateSettings(newSettings) {
+ this.settings = utils.extend({}, this.settings, newSettings);
+ this.checkSettings();
+ this.border = this.settings.border >= 0 ? this.settings.border : this.settings.margins;
+ this.maxRowHeight = this.retrieveMaxRowHeight();
+ this.suffixRanges = this.retrieveSuffixRanges();
+ }
+
+ get defaults() {
+ return {
+ sizeRangeSuffixes: {},
+ thumbnailPath: undefined,
+ rowHeight: 120,
+ maxRowHeight: false,
+ maxRowsCount: 0,
+ margins: 1,
+ border: -1,
+ lastRow: 'nojustify',
+ justifyThreshold: 0.90,
+ waitThumbnailsLoad: true,
+ captions: true,
+ cssAnimation: true,
+ imagesAnimationDuration: 500,
+ captionSettings: {
+ animationDuration: 500,
+ visibleOpacity: 0.7,
+ nonVisibleOpacity: 0.0
+ },
+ rel: null,
+ target: null,
+ extension: /\.[^.\\/]+$/,
+ refreshTime: 200,
+ refreshSensitivity: 0,
+ randomize: false,
+ rtl: false,
+ sort: false,
+ filter: false,
+ selector: 'a',
+ imgSelector: '> img, > a > img, > svg, > a > svg',
+ triggerEvent: function (event) {
+ utils.trigger(this.gallery, event);
+ }
+ };
+ }
+ }
+
+ function justifiedGallery(selector, arg) {
+ const galleries = typeof selector === 'string'
+ ? document.querySelectorAll(selector)
+ : (selector instanceof NodeList || Array.isArray(selector))
+ ? selector
+ : [selector];
+
+ Array.from(galleries).forEach((gallery) => {
+ utils.addClass(gallery, 'justified-gallery');
+
+ let controller = gallery._jgController;
+
+ if (typeof controller === 'undefined') {
+ if (typeof arg !== 'undefined' && arg !== null && !utils.isObject(arg)) {
+ if (arg === 'destroy') return;
+ throw 'The argument must be an object';
+ }
+ controller = new JustifiedGallery(gallery, arg);
+ gallery._jgController = controller;
+ } else if (arg === 'norewind') {
+ // Don't rewind
+ } else if (arg === 'destroy') {
+ controller.destroy();
+ delete gallery._jgController;
+ return;
+ } else {
+ controller.updateSettings(arg);
+ controller.rewind();
+ }
+
+ if (!controller.updateEntries(arg === 'norewind')) return;
+ controller.init();
+ });
+
+ return galleries;
+ }
+
+ return justifiedGallery;
+})();
+
+window.justifiedGallery = justifiedGallery;
diff --git a/scripts/oneko.js b/scripts/oneko.js
new file mode 100644
index 0000000..671d48c
--- /dev/null
+++ b/scripts/oneko.js
@@ -0,0 +1,279 @@
+// oneko.js: https://github.com/adryd325/oneko.js
+
+(function oneko() {
+ const isReducedMotion =
+ window.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
+ window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
+
+ if (isReducedMotion) return;
+
+ const nekoEl = document.createElement("div");
+ let persistPosition = true;
+
+ let nekoPosX = 32;
+ let nekoPosY = 32;
+
+ let mousePosX = 0;
+ let mousePosY = 0;
+
+ let frameCount = 0;
+ let idleTime = 0;
+ let idleAnimation = null;
+ let idleAnimationFrame = 0;
+
+ const nekoSpeed = 10;
+ const spriteSets = {
+ idle: [[-3, -3]],
+ alert: [[-7, -3]],
+ scratchSelf: [
+ [-5, 0],
+ [-6, 0],
+ [-7, 0],
+ ],
+ scratchWallN: [
+ [0, 0],
+ [0, -1],
+ ],
+ scratchWallS: [
+ [-7, -1],
+ [-6, -2],
+ ],
+ scratchWallE: [
+ [-2, -2],
+ [-2, -3],
+ ],
+ scratchWallW: [
+ [-4, 0],
+ [-4, -1],
+ ],
+ tired: [[-3, -2]],
+ sleeping: [
+ [-2, 0],
+ [-2, -1],
+ ],
+ N: [
+ [-1, -2],
+ [-1, -3],
+ ],
+ NE: [
+ [0, -2],
+ [0, -3],
+ ],
+ E: [
+ [-3, 0],
+ [-3, -1],
+ ],
+ SE: [
+ [-5, -1],
+ [-5, -2],
+ ],
+ S: [
+ [-6, -3],
+ [-7, -2],
+ ],
+ SW: [
+ [-5, -3],
+ [-6, -1],
+ ],
+ W: [
+ [-4, -2],
+ [-4, -3],
+ ],
+ NW: [
+ [-1, 0],
+ [-1, -1],
+ ],
+ };
+
+ function init() {
+ let nekoFile = "./oneko.gif"
+ const curScript = document.currentScript
+ if (curScript && curScript.dataset.cat) {
+ nekoFile = curScript.dataset.cat
+ }
+ if (curScript && curScript.dataset.persistPosition) {
+ if (curScript.dataset.persistPosition === "") {
+ persistPosition = true;
+ } else {
+ persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase());
+ }
+ }
+
+ if (persistPosition) {
+ let storedNeko = JSON.parse(window.localStorage.getItem("oneko"));
+ if (storedNeko !== null) {
+ nekoPosX = storedNeko.nekoPosX;
+ nekoPosY = storedNeko.nekoPosY;
+ mousePosX = storedNeko.mousePosX;
+ mousePosY = storedNeko.mousePosY;
+ frameCount = storedNeko.frameCount;
+ idleTime = storedNeko.idleTime;
+ idleAnimation = storedNeko.idleAnimation;
+ idleAnimationFrame = storedNeko.idleAnimationFrame;
+ nekoEl.style.backgroundPosition = storedNeko.bgPos;
+ }
+ }
+
+ nekoEl.id = "oneko";
+ nekoEl.ariaHidden = true;
+ nekoEl.style.width = "32px";
+ nekoEl.style.height = "32px";
+ nekoEl.style.position = "fixed";
+ nekoEl.style.pointerEvents = "none";
+ nekoEl.style.imageRendering = "pixelated";
+ nekoEl.style.left = `${nekoPosX - 16}px`;
+ nekoEl.style.top = `${nekoPosY - 16}px`;
+ nekoEl.style.zIndex = 2147483647;
+
+ nekoEl.style.backgroundImage = `url(${nekoFile})`;
+
+ document.body.appendChild(nekoEl);
+
+ document.addEventListener("mousemove", function (event) {
+ mousePosX = event.clientX;
+ mousePosY = event.clientY;
+ });
+
+ if (persistPosition) {
+ window.addEventListener("beforeunload", function (event) {
+ window.localStorage.setItem("oneko", JSON.stringify({
+ nekoPosX: nekoPosX,
+ nekoPosY: nekoPosY,
+ mousePosX: mousePosX,
+ mousePosY: mousePosY,
+ frameCount: frameCount,
+ idleTime: idleTime,
+ idleAnimation: idleAnimation,
+ idleAnimationFrame: idleAnimationFrame,
+ bgPos: nekoEl.style.backgroundPosition
+ }));
+ });
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ let lastFrameTimestamp;
+
+ function onAnimationFrame(timestamp) {
+ // Stops execution if the neko element is removed from DOM
+ if (!nekoEl.isConnected) {
+ return;
+ }
+ if (!lastFrameTimestamp) {
+ lastFrameTimestamp = timestamp;
+ }
+ if (timestamp - lastFrameTimestamp > 100) {
+ lastFrameTimestamp = timestamp;
+ frame();
+ }
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ function setSprite(name, frame) {
+ const sprite = spriteSets[name][frame % spriteSets[name].length];
+ nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
+ }
+
+ function resetIdleAnimation() {
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+ }
+
+ function idle() {
+ idleTime += 1;
+
+ // every ~ 20 seconds
+ if (
+ idleTime > 10 &&
+ Math.floor(Math.random() * 200) == 0 &&
+ idleAnimation == null
+ ) {
+ let avalibleIdleAnimations = ["sleeping", "scratchSelf"];
+ if (nekoPosX < 32) {
+ avalibleIdleAnimations.push("scratchWallW");
+ }
+ if (nekoPosY < 32) {
+ avalibleIdleAnimations.push("scratchWallN");
+ }
+ if (nekoPosX > window.innerWidth - 32) {
+ avalibleIdleAnimations.push("scratchWallE");
+ }
+ if (nekoPosY > window.innerHeight - 32) {
+ avalibleIdleAnimations.push("scratchWallS");
+ }
+ idleAnimation =
+ avalibleIdleAnimations[
+ Math.floor(Math.random() * avalibleIdleAnimations.length)
+ ];
+ }
+
+ switch (idleAnimation) {
+ case "sleeping":
+ if (idleAnimationFrame < 8) {
+ setSprite("tired", 0);
+ break;
+ }
+ setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
+ if (idleAnimationFrame > 192) {
+ resetIdleAnimation();
+ }
+ break;
+ case "scratchWallN":
+ case "scratchWallS":
+ case "scratchWallE":
+ case "scratchWallW":
+ case "scratchSelf":
+ setSprite(idleAnimation, idleAnimationFrame);
+ if (idleAnimationFrame > 9) {
+ resetIdleAnimation();
+ }
+ break;
+ default:
+ setSprite("idle", 0);
+ return;
+ }
+ idleAnimationFrame += 1;
+ }
+
+ function frame() {
+ frameCount += 1;
+ const diffX = nekoPosX - mousePosX;
+ const diffY = nekoPosY - mousePosY;
+ const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
+
+ if (distance < nekoSpeed || distance < 48) {
+ idle();
+ return;
+ }
+
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+
+ if (idleTime > 1) {
+ setSprite("alert", 0);
+ // count down after being alerted before moving
+ idleTime = Math.min(idleTime, 7);
+ idleTime -= 1;
+ return;
+ }
+
+ let direction;
+ direction = diffY / distance > 0.5 ? "N" : "";
+ direction += diffY / distance < -0.5 ? "S" : "";
+ direction += diffX / distance > 0.5 ? "W" : "";
+ direction += diffX / distance < -0.5 ? "E" : "";
+ setSprite(direction, frameCount);
+
+ nekoPosX -= (diffX / distance) * nekoSpeed;
+ nekoPosY -= (diffY / distance) * nekoSpeed;
+
+ nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
+ nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
+
+ nekoEl.style.left = `${nekoPosX - 16}px`;
+ nekoEl.style.top = `${nekoPosY - 16}px`;
+ }
+
+ init();
+})();