Development Log
Animated Dynamic SVG user account mascot
The code presented here adds a cloud riding entity to the user account page. It follows the mouse cursor with its eye. It hovers left and right depending on the position of the mouse cursor. The eye is animated twofold - the pupil and the eyeball move independently, creating an illusion of depths and cohesion. The animations are done using CSS and linear interpolation of values to move the SVG elements. No point data animations were used. The SVG elements are only animated statically this time around. If you don’t have a user account, you can create one for free. The entity on its cloud only exists on the user account page which you can access by clicking the blinking, horned icon in the top right.
Do not be discouraged by the massive SVG point data in the HTML block. The vast majority of numbers defines the shape of the entity and the cloud, while the animated elements are mere circles at the end.
HTML: <div id="accountMascotContainer"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2000 2000" id="accountMascotSVG"><g id="mascotMainFigure"><path id="mascotFigure" d="M836.25,1709.45c-21.31,23.28-45.25,38.72-74.74,42.22-52.68,6.26-100.79-2.36-135.26-47.95-4.3-5.69-8.2-4.61-13.44-2.65-38.99,16.33-95.96,5.66-109.55-39.08-2.62-8.58-6.83-8.96-14.24-8.26-60.58,5.7-112.88-31.13-127.75-89.56-17.45-68.56,29.94-135.9,101.2-143.31,7.78-.81,10.08-3.75,10.66-11.23,3.61-47.24,25.61-70.83,72.03-77.72,6.45-.96,11.49-3.03,14.37-9.44,8.93-19.87,24.03-33.95,43.25-43.42,7.76-3.82,6.33-6.47,2.07-12.29-47.67-67.52,12-128.07,82.18-110.96q30.27,5.75,40.58-23.95c29.77-84.93,52.4-171.96,76.23-258.63,3.8-13.83,10.64-24.62,23.48-31.15,27.1-13.77,54.33-27.3,81.41-41.12,14.32-7.31,28.76-14.65,39.07-27.61,11.15-14.02,9.31-19.28-7.75-23.24-299.63-71.98-279.56-502.27,28.78-539.28,249.92-24.88,394.61,276.72,225.52,458.76-40.16,43.75-90.33,71.4-149.29,81.97-15.23,2.73-16.06,4.65-7.25,17.84,9.88,14.79,25.34,22.17,40.36,29.89,25.87,13.3,52.5,25.07,77.88,39.42,14.59,8.25,22.96,19.85,27.5,35.92,25.91,93.29,48.55,187.59,81.11,278.9,2.9,9.27,7.64,10.58,16.61,7.8,18.24-5.65,36.78-9.94,56.33-6.87,55.06,8.63,78.55,64.33,45.02,108.93-7.86,10.45-5.36,11.99,5.51,14.91,24.06,6.46,42.6,21.48,55.7,42.4,4.76,7.61,8.92,10.49,18.36,9.8,45.65-3.36,83.4,29.77,86.02,75.41.59,10.22,4.91,13.2,13.9,14.69,103.35,16.08,136.31,151.52,51.67,212.76-24.94,19.48-54.3,27.22-85.98,20.48-9.75-2.07-13.71.27-16.87,9.71-6.95,20.82-22.41,33.9-42.98,40.36-26.76,8.41-53.15,6.98-78.53-5.69-5.71-2.85-10.1-5.33-14.97,2.51-44.91,71.87-120.84,54.77-188.46,31.48-14.68-4.42-24.41-15.94-33.33-30.24-9.94,10.36-18.78,19.86-27.98,28.99-3.89,3.86-3.44,8.64-4.36,13.24-3.21,16.05-6.52,32.4-25.83,36.8-15.92,4.19-37.26-14.31-44.75-33.57-2.6-8.87-7.65-8.67-14.84-5.26-39.81,18.88-77.29,11.3-112.09-12.3-11.02-7.47-13.01-4.09-15.36,6.04-6.65,36.32-57.09,70.08-71.76,19.62-3.74-14.73-.07-29.22.58-46.11ZM1397.46,1398.88c-2.82-2.24-3.79-2.63-4.09-3.32-1.4-3.17-2.53-6.45-3.91-9.62-10.44-24.04-24.84-44.62-48.54-57.62-25.8-14.15-34.72-13.62-56.86,6.36-66.97,60.42-118.51,132.37-157.57,213.37-22.28,46.21-22.34,94.75-13.16,144.01,1.74,9.32,5.31,10.95,12.69,4.44,7.26-6.41,13.05-13.9,18.17-22q11.15-17.65,22.8-.15c26.39,38.84,91.27,60.78,138.64,47.11,30.65-8.99,51.93-28.98,63.03-59.14,6.17-16.75,6.48-17.37,18.42-3.84,28.66,32.51,96.41,28.5,109.15-25.16,2.25-9.48,6.37-9.23,13.74-6.39,68.13,25.45,135.81-36.56,122.96-109.21-5.88-37.84-46.51-87.54-103.69-77.06-7.08,1.3-8.78-.12-6.24-7.03,5.23-14.26,2.62-28.43-2.34-42.03-8.28-22.67-36.42-36.16-58.36-29.63-10.51,3.13-13.94,12.5-21.05,22.1-3.59-22.46-8.56-41.59-22.49-57.15-18.23-20.36-40.94-31.76-67.84-34.9-5.13-.6-11.7-.05-13.79,5.12-2.26,5.59,4.6,6.36,7.94,8.22,30.71,17.06,57.45,57.03,52.4,93.52ZM558.48,1383.18c-4.47-6.54-4.04-12.59-3.46-18.56.47-4.83-.46-7.08-6.25-6.71-34.28,2.17-61.41,36.66-54.61,70.77,2.02,10.13,1.76,13.4-10.57,11.98-59.16-6.85-111.53,52.92-98.23,111.11,13.21,57.77,62.5,88.64,123.34,76.6,7.85-1.55,10.99-.66,12.28,7.53,3.82,24.29,20.96,35.1,42.94,39.14,22.27,4.1,43.75,2.51,58.7-17.84,5.49-7.48,8.42-9.04,12.5.92,34.82,85.08,135.57,79.39,178.54,39.78,4.65-4.29,8.95-6.8,14.81-2.6,8.25,5.91,10.5.68,11.53-6.37,5.89-40.38,6.74-80.48-7.35-119.61-31.5-87.49-85.4-160.35-148.36-227.34-7.15-7.61-13.67-6.06-20.41-.52-18.24,14.99-25.89,35.25-28.32,58.04-.44,4.13.86,10.41-4,11.27-4.99.89-5.65-5.56-7.48-9.27-11.21-30.63.68-59.7,21.8-82.11,2.75-2.98,7.61-4.66,4.84-10.52-4.53-9.6-13.53-13.54-23.56-9.39-30.29,12.53-50.24,34.74-61.18,65.46-1.99,5.58-1.98,11.98-7.5,18.24ZM992.91,1723.74c9.03.41,20.67-2.45,32.2-6.88,9.33-3.59,12.01-9.39,11-19.16-2.68-25.78-9.41-50.11-20.67-73.55-13.97-29.09-9.63-45.4,17.26-63.24,13.69-9.08,21.91-20.65,25.25-36.21,12.22-56.99,28.26-112.9,47.71-167.83,3.07-8.67-.12-12.15-8.47-12.86-21.78-1.84-40.32,4.07-54.12,22.01-2.75,3.58-4.11,10.18-9.79,9.1-5.26-1-5.01-7.56-6.86-11.79-12.5-28.61-31.89-48.67-64.73-51.21-35.65-2.76-62.59,11.41-79.3,43.32-4.7,8.97-7.94,12.71-13.36.73-1.12-2.47-4.18-4.49-6.81-5.73-3.81-1.8-8.48-5.23-12.16-2.53-4.43,3.25-.39,8.1.91,11.86,18.28,53.11,31.04,107.66,42.24,162.58,3.23,15.84,10.46,27.68,23.71,37.19,28.93,20.78,32.15,35.65,16.58,67.97-7.36,15.28-14.06,30.65-17.01,47.66-1.73,9.93.98,16.82,7.93,23.12,18.49,16.78,40.07,25.54,68.5,25.46ZM858.63,1015.3c-.94,1.82-2.16,3.54-2.77,5.46-14.63,46.31-31.12,91.96-48.36,137.36-8.47,22.3-14.58,21.54,16.05,28.04,24.38,5.17,24.88,5.28,32.16-18.92,5.6-18.61,16.41-36.29,15.94-55.97-.77-31.99-5.21-63.79-13.02-95.98ZM1128.96,1016.72c-.81.1-1.63.19-2.44.29-1.61,11.56-3.5,23.08-4.76,34.68-3.83,35.11-10.74,69.74,4.42,104.81,15.32,35.45,13.54,35.85,51.2,27.85,8.83-1.88,9.05-4.8,6.28-12.12-19.53-51.38-38.2-103.07-54.7-155.51ZM752.8,1199.75c57.46,11.61,117.02,11.11,174.39,38.2-19.45-16.53-39.13-24.5-59.9-29.07-60.62-13.33-121.8-23.99-182.21-38.48-20.39-4.89-41.6-3.73-60.61,7.9-24.49,14.98-30.64,55.2-10.41,74.75-7.28-21.83-5.79-41.21,10.34-57.64,19.94-20.31,44.69-23.27,76.82-7.84,30.17,14.48,59.24,30.99,86.54,50.53,23.86,17.09,45.64,36.7,68.22,55.42-14.32-18.56-30.15-35.58-47.64-51.09-17.41-15.43-36.74-28.33-55.53-42.69ZM1232.31,1201.84c-40.63,27.59-76.22,60.08-107.96,96.85,33.16-29.5,67.83-56.99,105.79-80.04,37.68-23.09,92.97-64.71,132.28-22.82,15.08,16.37,16.94,35.44,8.58,55.79-.53,1.29-2.19,2.85.33,4.68,13.46-16.81,15.7-35.44,8.4-55.01-7.21-19.34-22.54-29.37-42.47-31.97-14.24-1.86-28.59-1.59-42.68,2.12-17.21,4.54-34.29,9.75-51.71,13.23-41.27,8.25-82.76,15.4-124.07,23.5-19.58,3.84-37.55,11.63-52.53,25.13,52.57-24.47,110.12-22.31,166.03-31.46ZM751.95,1098.31c27.91-68.66,51-138.78,73.39-209.1,5.82-18.29,16.05-30.69,32.47-39.99,22.81-12.91,45.08-26.83,65.61-43.39-31.03,16.3-61.54,33.46-92.03,50.66-7.82,4.41-10.9,11.98-13.24,20.1-21.45,74.03-42.61,148.46-66.19,221.71ZM1221.4,1068.12c.63-.18,1.25-.35,1.88-.53.08-1.19.51-2.48.2-3.56-18.66-64.54-37.44-129.05-56-193.62-2.09-7.27-6.53-12.25-12.67-15.75-31.82-18.19-63.76-36.18-96.83-54.91,2.49,5.67,6.76,7.2,10.19,9.43,24.01,15.56,48.05,31.06,72.27,46.28,8.1,5.09,13.43,11.65,16.43,20.85,21,64.11,42.07,128.2,64.52,191.82Z"/></g><g id="mascotEyes"><circle id="mascotEyeSocket" cx="997.85" cy="478.11" r="243.34"/><circle id="mascotEye" cx="1000" cy="475.37" r="76.16"/><circle id="mascotPupil" cx="1000.03" cy="475.37" r="31.63"/></g></svg> </div>
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function() { const accountMascotContainer = document.getElementById('accountMascotContainer'); //check if mobile const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { //are we on account page const subString = '/useraccount'; const getURL = window.location.href; if (getURL.includes(subString)) { //attach the mascot const placeholder = document.getElementById('block-yui_3_17_2_1_1724140813354_62562'); placeholder.innerHTML = ''; placeholder.appendChild(accountMascotContainer); // Animated eye and mouse coordinates let mouseX = 0; let mouseY = 0; function lerp(start, end, t) { return start + (end - start) * t; } const eyeSensitivity = 0.08; // Sensitivity for eye movement const pupilSensitivity = 0.01; // Sensitivity for pupil movement const svgElement = document.getElementById("accountMascotSVG"); const eyeSocket = document.getElementById('mascotEyeSocket'); const eye = document.getElementById('mascotEye'); const pupil = document.getElementById('mascotPupil'); const eyeSocketCenterX = parseFloat(eyeSocket.getAttribute('cx')); const eyeSocketCenterY = parseFloat(eyeSocket.getAttribute('cy')); const eyeSocketRadius = parseFloat(eyeSocket.getAttribute('r')); const eyeRadius = parseFloat(eye.getAttribute('r')); const pupilRadius = parseFloat(pupil.getAttribute('r')); let targetEyeX = eyeSocketCenterX; let targetEyeY = eyeSocketCenterY; let targetPupilX = eyeSocketCenterX; let targetPupilY = eyeSocketCenterY; let currentEyeX = parseFloat(eye.getAttribute('cx')); let currentEyeY = parseFloat(eye.getAttribute('cy')); let currentPupilX = parseFloat(pupil.getAttribute('cx')); let currentPupilY = parseFloat(pupil.getAttribute('cy')); // Update the target positions on mouse move document.addEventListener('mousemove', (e) => { const point = svgElement.createSVGPoint(); mouseX = e.clientX; mouseY = e.clientY; point.x = e.clientX; point.y = e.clientY; //convert to SVG space coordinates const transformedPoint = point.matrixTransform(svgElement.getScreenCTM().inverse()); const angleX = (transformedPoint.x - eyeSocketCenterX) * eyeSensitivity; const angleY = (transformedPoint.y - eyeSocketCenterY) * eyeSensitivity; const maxDistance = eyeSocketRadius - eyeRadius; //constrain to head const distance = Math.min(Math.sqrt(angleX ** 2 + angleY ** 2), maxDistance); targetEyeX = eyeSocketCenterX + distance * (angleX / Math.sqrt(angleX ** 2 + angleY ** 2)); targetEyeY = eyeSocketCenterY + distance * (angleY / Math.sqrt(angleX ** 2 + angleY ** 2)); const pupilAngleX = (transformedPoint.x - currentEyeX) * pupilSensitivity; const pupilAngleY = (transformedPoint.y - currentEyeY) * pupilSensitivity; const maxPupilDistance = eyeRadius - pupilRadius; const pupilDistance = Math.min(Math.sqrt(pupilAngleX ** 2 + pupilAngleY ** 2), maxPupilDistance); targetPupilX = targetEyeX + pupilDistance * (pupilAngleX / Math.sqrt(pupilAngleX ** 2 + pupilAngleY ** 2)); targetPupilY = targetEyeY + pupilDistance * (pupilAngleY / Math.sqrt(pupilAngleX ** 2 + pupilAngleY ** 2)); }); function animate() { const eyeLerpSpeed = 0.1; // interpolation smoothness currentEyeX = lerp(currentEyeX, targetEyeX, eyeLerpSpeed); currentEyeY = lerp(currentEyeY, targetEyeY, eyeLerpSpeed); eye.setAttribute('cx', currentEyeX); eye.setAttribute('cy', currentEyeY); currentPupilX = lerp(currentPupilX, targetPupilX, eyeLerpSpeed); currentPupilY = lerp(currentPupilY, targetPupilY, eyeLerpSpeed); pupil.setAttribute('cx', currentPupilX); pupil.setAttribute('cy', currentPupilY); requestAnimationFrame(animate); } animate(); //animated cloud hover let currentContainerX = 0; const windowWidth = window.innerWidth; const maxPositionX = windowWidth * 0.5; const maxPositionY = 100; //max height of cloud movement in pixels const cloudLerpSpeed = 0.005; const thresholdX = 50; //distance debounce for smoothness function animateContainer() { const targetX = Math.max(-maxPositionX / 2, Math.min(maxPositionX / 2, mouseX - (windowWidth / 2))); if (Math.abs(targetX - currentContainerX) > thresholdX) { currentContainerX = lerp(currentContainerX, targetX, cloudLerpSpeed); } accountMascotContainer.style.transform = `translateX(${currentContainerX}px)`; requestAnimationFrame(animateContainer); } animateContainer(); //exit } else { accountMascotContainer.remove(); } } else { accountMascotContainer.remove(); } }); </script>
CSS: @keyframes cloudBounce { 0% {transform: translateY(-30px)} 50% {transform: translateY(10px)} 100% {transform: translateY(-30px)} } #accountMascotContainer { align-self: center; width: 350px; height: 350px; transform-origin: center; margin: 0; padding: 0; margin-top: -20px; margin-bottom: -250px; z-index: 999; pointer-events: none; position: absolute; overflow: visible; } #accountMascotSVG { width: 100%; height: 100%; animation: cloudBounce 10s linear infinite } #mascotEye { transition: fill 3s ease; pointer-events: auto; fill: var(--color1); } #mascotEye:hover { fill: var(--color3); }
Animated Logo with CSS and SVG
The presented script animates the logo you see in the header (disabled on mobile). The frequency of shuffling the animations has been greatly increased on this page for better illustration. On any other page the logo only occasionally breaks its static demeanor by going nuts briefly. The resulting animations are achieved by combining CSS key frame animations with linear SVG point data interpolation. There is some code at the end to make them match up, with a bit of randomness added to it. This effectively gives more depth to the animations, as the mixing and matching of the two techniques allows for a grade of controlled unpredictability.
I drew the SVG (scalable vector graphic) of my logo in adobe illustrator and the resulting point data can be seen in the HTML below. Then I substituted the original, static logo for my SVG. A few CSS animations have been created, and a script structure built for their randomization and triggering. Then I implemented the morphing of point data of my logo into other shapes. I also have a simple loop to make sure an animation is never played twice in a row, other than that, the CSS animations are selected at random and there is conditional logic which pairs them with the SVG point data interpolations.
You may notice that I have two arrays for my CSS animations instead of one. This was needed because certain properties can only be animated on the SVG element itself which largely behaves as a DIV (the majority of the animations, such as blur and large translation values only work on the SVG container), while other properties, such as shape color fill and border manipulations, can only be animated on the polygon shape within the SVG element.
To start the selection cycle I assigned an introductory animation to the new logo in CSS, supplemented by the SVG morphing into a shape resembling my artist signature. I made sure that the point morphing logic smoothly returns the shape back to the original, after each animation cycle. After creating the functionality for both animation techniques to work together, some effort was required to make sure the pairing looks presentable in terms of timing.
A lot more is possible with morphing SVG point data than presented in this proof of concept. The best approach is to initially draw your SVG such that it has all the extra points you might need for animating it, even if the original shape does not require them, although nothing is stopping you from changing your original SVG completely, as long as you don’t mind forfeiting the morphs which might have already been made for it prior.
HTML: <svg id="PolivantageLogo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1200"> <polygon id="PolivantageLogoOutline" points="585.11 0 718.17 0 201.66 899.84 328.87 899.84 780.93 113.56 1303.52 1014.94 910.69 1014.94 715.46 680.55 783.29 565.37 979.53 900.22 1108.88 901.57 779.45 345.43 400.01 1014.94 0 1014.94 585.11 0"/> </svg>
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const newLogo = document.getElementById('PolivantageLogo'); //mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { //replace the old logo const oldLogoPlace = document.querySelector('.header-title-logo'); const oldLogoImage = oldLogoPlace.querySelector('img'); oldLogoImage.style.opacity = '0'; oldLogoPlace.appendChild(newLogo); //assign link to homepage newLogo.addEventListener("click", (event) => { event.preventDefault(); window.location.href = '/'; }); //main setup const logoOutline = document.getElementById('PolivantageLogoOutline'); let animationPlaying = false; let animatingOutline = true; let previousAnimation = 'none'; let currentAnimation = 'none'; let minInterval = 1 * 60 * 1000; let maxInterval = 2 * 60 * 1000; //for illustration purposes decreased interval on dedicated blog page const subString = '/developmentlog/animatedlogo'; const getURL = window.location.href; if (getURL.includes(subString)) { minInterval = 1000; maxInterval = 2000; } //arrays of css animations const outlineAnimationsArray = [ 'logoPulseRed 4s linear 2 forwards', 'logoStrobeWhite 0.3s linear 4 forwards' ]; const logoAnimationsArray = [ 'logoFlyAway 17s ease 1 forwards', 'logoRotate 10s ease 1 forwards', 'pulse 0.4s ease 5 forwards', 'logoTumble 17s ease 1 forwards', 'logoFall 10s ease 1 forwards', 'logoDematerial 10s ease 1 forwards', 'logoTeleport 20s ease 1 forwards', 'logoShrink 9s ease 1 forwards', 'logoBump 12s ease 1 forwards', 'logoSlide 20s ease 1 forwards', 'logoSpin 10s ease 1 forwards', 'logoTriRotate 6s ease 1 forwards' ]; const leanToOutlineAnimation = outlineAnimationsArray.length / (outlineAnimationsArray.length + logoAnimationsArray.length); //random animation selection function getRandomInterval(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function getRandomAnimation() { if (animatingOutline === true) { const randomIndex = Math.floor(Math.random() * outlineAnimationsArray.length); return outlineAnimationsArray[randomIndex]; } else { const randomIndex = Math.floor(Math.random() * logoAnimationsArray.length); return logoAnimationsArray[randomIndex]; } } function startRandomAnimations() { if (animationPlaying === false) { animatingOutline = Math.random() < leanToOutlineAnimation; animationPlaying = true; do { //never play the same animation twice in a row currentAnimation = getRandomAnimation(); } while (currentAnimation === previousAnimation || currentAnimation === 'none'); if (animatingOutline === true) { logoOutline.style.animation = currentAnimation; } else { newLogo.style.animation = currentAnimation; newLogo.style.pointerEvents = 'none'; } previousAnimation = currentAnimation; mapPointToCssAnims(); // attach point animation to css animation } } //trigger the animations function triggerAnimation() { animationPlaying = false; logoOutline.style.animation = 'none'; logoOutline.offsetHeight; newLogo.style.animation = 'none'; newLogo.offsetHeight; newLogo.style.pointerEvents = 'auto'; setTimeout(() => { startRandomAnimations(); }, getRandomInterval(minInterval, maxInterval)); } logoOutline.addEventListener('animationend', () => { triggerAnimation(); }); newLogo.addEventListener('animationend', () => { triggerAnimation(); }); //svg point data animations const originalPoints = logoOutline.getAttribute('points'); const pointAnimations = new Map([ [ "pyramid", "585.11 0 718.17 0 201.66 899.84 201.66 899.84 718.17 0 1303.52 1014.94 910.69 1014.94 400.01 1014.94 779.45 345.43 1108.88 901.57 1108.88 901.57 779.45 345.43 400.01 1014.94 0 1014.94 585.11 0" ], [ "line", ".85 0 201.66 0 201.66 0 328.87 0 780.93 0 1302.01 0 1302.01 114.97 780.93 114.97 780.93 0 979.53 .38 1108.88 1.72 780.93 114.97 400.01 115.09 0 115.09 .85 0" ], [ "arrow", "0 1184.37 244.27 786.29 244.27 786.29 244.27 786.29 696.33 0 1218.92 901.38 1218.92 901.38 1371.94 1184.37 1250.44 1184.37 1024.28 788.01 1024.28 788.01 694.85 231.87 315.41 901.38 147.46 1184.37 0 1184.37" ], [ "letterM", "526.25 0 591.49 114.26 201.66 793.42 328.87 793.42 780.93 7.13 1303.52 908.51 910.69 908.51 455.38 118.6 526.25 0 979.53 793.79 1108.88 795.14 779.45 239 400.01 908.51 0 908.51 526.25 0" ], [ "slightAlterationOne", "519.04 1.05 641.67 1.05 201.66 786.29 328.87 786.29 780.93 0 1303.52 901.38 910.69 901.38 518.54 901.26 584.88 788.64 979.53 786.66 1108.88 788.01 779.45 231.87 400.01 901.38 0 901.38 519.04 1.05" ], [ "slightAlterationTwo", "0 1.05 122.63 1.05 410.43 786.29 537.63 786.29 989.7 0 1512.29 901.38 1050.71 901.26 916.23 1212.55 777.03 1212.55 989.7 787.66 1317.64 788.01 988.22 231.87 608.77 901.38 316.96 901.26 0 1.05" ], [ "cube", ".85 .31 718.17 0 718.17 0 780.93 .31 1302.01 .31 1303.52 1014.94 910.69 1014.94 400.01 1014.94 779.45 345.43 1108.88 901.57 1108.88 901.57 779.45 345.43 400.01 1014.94 0 1014.94 .85 .31" ], [ "six", "0 0 150.13 0 149.01 891.95 614.25 892.48 621 0 1303.52 0 1303.52 1014.5 719.89 1014.5 772.71 893.07 1154.79 893.07 1162.66 132.38 786.19 133.39 782.82 1014.5 0 1014.63 0 0" ], [ "LP", "0 0 116.44 1.13 124.15 900.67 545.7 899.53 561.43 0 1303.52 0 1303.52 649.21 655.22 653.68 656.29 542.53 1190.76 542.53 1196.37 117.66 660.33 122.15 651.76 1014.5 0 1014.63 0 0" ], [ "hollowTriangle", "651.76 0 651.76 0 651.76 0 651.76 0 651.76 0 1303.52 1014.63 910.69 1014.63 0 1014.63 90.36 873.96 1075.02 878.55 1075.02 878.55 651.76 217.67 172.61 1014.5 0 1014.63 651.76 0" ] ]); let animatingToTarget = true; let pointAnimationActive = true; let pointAnimDuration = 3 * 1000; let waitBeforeRevert = 3 * 1000; //get random points animation function getRandomPointsAnimation() { const keys = Array.from(pointAnimations.keys()); const randomIndex = Math.floor(Math.random() * keys.length); return keys[randomIndex]; } let selectedAnimation = getRandomPointsAnimation(); //to animate points function interpolatePoints(start, end, progress) { const startPoints = start.split(' ').map(Number); const endPoints = end.split(' ').map(Number); const interpolated = startPoints.map((startPoint, index) => { return startPoint + (endPoints[index] - startPoint) * progress; }); return interpolated.join(' '); } function animatePoints(animationName, duration, waitBeforeRevert) { if (!pointAnimationActive) return; const targetPoints = pointAnimations.get(animationName); const startTime = performance.now(); function animate() { const currentTime = performance.now(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const newPoints = interpolatePoints( animatingToTarget ? originalPoints : targetPoints, animatingToTarget ? targetPoints : originalPoints, progress ); logoOutline.setAttribute('points', newPoints); if (progress < 1) { requestAnimationFrame(animate); } else { animatingToTarget = !animatingToTarget; if (animatingToTarget) { pointAnimationActive = false; pointAnimationComplete(); } else { setTimeout(() => { animatePoints(animationName, duration, waitBeforeRevert); }, waitBeforeRevert); } } } requestAnimationFrame(animate); } function pointAnimationComplete() { pointAnimationActive = true; } //combining css and point animations function mapPointToCssAnims(){ if (currentAnimation === 'logoFall 10s ease 1 forwards') { animatePoints(Math.random() > 0.7 ? 'pyramid' : 'cube', 500, 8000); } else if (currentAnimation === 'logoBump 12s ease 1 forwards') { animatePoints('arrow', 1200, 9500); } else if (currentAnimation === 'logoFlyAway 17s ease 1 forwards') { animatePoints(Math.random() > 0.5 ? 'slightAlterationOne' : 'letterM', 700, 15000); } else if (currentAnimation === 'logoShrink 9s ease 1 forwards') { animatePoints(Math.random() > 0.5 ? 'slightAlterationOne' : 'letterM', 500, 8000); } else if (currentAnimation === 'logoRotate 10s ease 1 forwards') { if (Math.random() > 0.5) { animatePoints(Math.random() > 0.35 ? 'slightAlterationTwo' : 'hollowTriangle', 1000, 8000); } } else if (currentAnimation === 'logoSlide 20s ease 1 forwards') { animatePoints('line', 700, 16500); } else if (currentAnimation === 'logoTumble 17s ease 1 forwards') { if (Math.random() > 0.8) { animatePoints('pyramid', 600, 13100); } } else if (currentAnimation === 'logoSpin 10s ease 1 forwards') { animatePoints('six', 700, 8000); } else if (currentAnimation === 'logoDematerial 10s ease 1 forwards') { if (Math.random() > 0.3) { animatePoints('LP', 1000, 8000); } } else if (currentAnimation === 'logoTriRotate 6s ease 1 forwards') { if (Math.random() > 0.3) { animatePoints('hollowTriangle', 500, 5000); } } } animatePoints('LP', 550, 3630); //intro point anim //exit } else { newLogo.remove(); } }); </script>
CSS: @keyframes logoFadeIn { 0% {opacity: 0; filter: blur(50px); transform: rotate(90deg);} 25% {opacity: 1; filter: blur(0px); transform: rotate(90deg);} 40% {transform: rotate(-15deg);} 60% {transform: rotate(0deg);} 100% {opacity: 1; transform: rotate(0deg)} } @keyframes logoPulseRed { 0% {fill: black;} 50% {fill: var(--color3);} 100% {fill: black;} } @keyframes logoStrobeWhite { 0% {fill: black;} 25% {fill: white;} 50% {fill: black;} 75% {fill: white;} 100% {fill: black;} } @keyframes logoShrink { 0% {transform: scale(1) translateY(0px) rotate(0deg);} 10% {transform: scale(0.7) translateY(15px);} 15% {transform: scale(0.7) translateY(15px);} 30% {transform: scale(0.7) translateY(-300px) rotate(0deg);} 35% {transform: scale(0.5) translateY(-300px) rotate(180deg);} 50% {transform: scale(0.5) translateY(20px) rotate(180deg);} 55% {transform: scale(0.5) translateY(0px) rotate(180deg);} 60% {transform: scale(0.5) translateY(20px) rotate(180deg);} 65% {transform: scale(0.5) translateY(0px) rotate(180deg);} 70% {transform: scale(0.5) translateY(20px) rotate(180deg);} 75% {transform: scale(0.5) translateY(0px) rotate(180deg);} 80% {transform: scale(0.5) translateY(20px) rotate(180deg);} 90% {transform: scale(0.5) translateY(20px) rotate(360deg);} 100% {transform: scale(1) translateY(0px) rotate(360deg);} } @keyframes logoFlyAway { 0% {opacity: 1; transform: scale(1) rotate(0deg) translateY(0) scaleY(1);} 5% {opacity: 1; transform: scale(1) rotate(0deg) translateY(0) scaleY(1);} 10% {opacity: 1; transform: scale(0.6) rotate(-90deg) translateY(0) scaleY(1);} 15% {opacity: 1; transform: scale(0.6) rotate(-90deg) translateY(0) scaleY(1);} 35% {opacity: 0; transform: scale(0.6) rotate(-90deg) translateY(-1700px) scaleY(1);} 65% {opacity: 0; transform: scale(0.6) rotate(-90deg) translateY(-1700px) scaleY(1);} 85% {opacity: 0; transform: scale(0.6) rotate(-90deg) translateY(-1700px) scaleY(1);} 85.01% {opacity: 0; transform: scale(0.0) rotate(90deg) translateY(1700px) scaleY(1);} 85.02% {opacity: 0; transform: scale(0.6) rotate(90deg) translateY(1700px) scaleY(1);} 95% {opacity: 1; transform: scale(0.75) rotate(90deg) translateY(0) scaleY(1);} 100% {opacity: 1; transform: scale(1) rotate(0deg) translateY(0) scaleY(1);} } @keyframes logoTumble { 0% {transform: scale(1) translateY(0px);} 3% {transform: scale(0.9) translateY(0px);} 4% {transform: scale(0.9) translateY(26px);} 12% {transform: scale(0.9) translateY(26px);} 25% {transform: scale(0.9) translateY(3px) translateX(-26px) rotate(-45deg);} 28% {transform: scale(0.9) translateY(24px) translateX(-48px) rotate(-120deg);} 35% {transform: scale(0.9) translateY(16px) translateX(-90px) rotate(-180deg);} 41% {transform: scale(0.9) translateY(34px) translateX(-130px) rotate(-240deg);} 58% {transform: scale(0.9) translateY(34px) translateX(-130px) rotate(-240deg);} 67% {transform: scale(0.9) translateY(9px) translateX(-180px) rotate(-310deg);} 71% {transform: scale(0.9) translateY(26px) translateX(-210px) rotate(-360deg);} 79% {transform: scale(0.9) translateY(3px) translateX(-230px) rotate(-405deg);} 82% {transform: scale(0.9) translateY(12px) translateX(-190px) rotate(-340deg);} 84% {transform: scale(0.9) translateY(8px) translateX(-180px) rotate(-390deg);} 90% {transform: scale(0.9) translateY(0px) translateX(-90px) rotate(-250deg);} 96% {transform: scale(0.9) translateY(0px) translateX(5px) rotate(-375deg);} 100% {transform: scale(1) translateY(0px) translateX(0px) rotate(-360deg);} } @keyframes logoFall { 0% {transform: scale(1) translateY(0px);} 5% {transform: scale(0.9, 0.9) translateY(0px);} 10% {transform: scale(0.9, 0.9) translateY(26px);} 30% {transform: scale(0.1, 0.9) translateY(26px);} 60% {transform: scale(1.3, 0.9) translateY(26px);} 80% {transform: scale(0.76, 0.9) translateY(26px);} 85% {transform: scale(0.9, 0.9) translateY(26px);} 95% {transform: scale(0.9, 0.9) translateY(0px);} 100% {transform: scale(1) translateY(0px);} } @keyframes logoDematerial { 0% {opacity: 1; filter: blur(0px); transform: scale(1);} 20% {opacity: 0.2; filter: blur(5px); transform: scale(1);} 40% {opacity: 1; filter: blur(0px); transform: scale(1);} 50% {opacity: 1; filter: blur(0px); transform: scale(1);} 70% {opacity: 0; filter: blur(50px); transform: scale(2);} 80% {opacity: 0; filter: blur(50px); transform: scale(2);} 100% {opacity: 1; filter: blur(0px); transform: scale(1);} } @keyframes logoRotate { 0% {transform: translateY(0px) rotate(0deg) scale(1);} 10% {transform: translateY(0px) rotate(180deg) scale(0.85);} 20% {transform: translateY(-19px) rotate(180deg) scale(0.8);} 30% {transform: translateY(5px) rotate(180deg) scale(0.8);} 40% {transform: translateY(-19px) rotate(180deg) scale(0.8);} 50% {transform: translateY(5px) rotate(180deg) scale(0.8);} 60% {transform: translateY(-19px) rotate(180deg) scale(0.8);} 70% {transform: translateY(5px) rotate(180deg) scale(0.8);} 80% {transform: translateY(-19px) rotate(180deg) scale(0.8);} 90% {transform: translateY(-8px) rotate(180deg) scale(0.85);} 100% {transform: translateY(0px) rotate(360deg) scale(1);} } @keyframes logoTeleport { 0% {opacity: 1; filter: blur(0px); transform: translateX(0px)} 16% {opacity: 0; filter: blur(50px); transform: translateX(0px)} 17% {opacity: 0; filter: blur(50px); transform: translateX(-300px)} 23% {opacity: 1; filter: blur(0px); transform: translateX(-300px)} 26% {opacity: 1; filter: blur(0px); transform: translateX(-300px)} 34% {opacity: 0; filter: blur(50px); transform: translateX(-300px)} 35% {opacity: 0; filter: blur(50px); transform: translateX(300px)} 40% {opacity: 1; filter: blur(0px); transform: translateX(300px)} 50% {opacity: 1; filter: blur(0px); transform: translateX(300px)} 60% {opacity: 0; filter: blur(50px); transform: translateX(300px)} 63% {opacity: 0; filter: blur(50px); transform: translateX(-100px)} 70% {opacity: 1; filter: blur(0px); transform: translateX(-100px)} 75% {opacity: 1; filter: blur(0px); transform: translateX(-100px)} 90% {opacity: 0; filter: blur(50px); transform: translateX(-100px)} 93% {opacity: 0; filter: blur(50px); transform: translateX(0px)} 100% {opacity: 1; filter: blur(0px); transform: translateX(0px)} } @keyframes logoBump { 0% {transform: scale(1) translateY(0px);} 3% {transform: scale(0.9) translateX(0px) translateY(-10px) rotate(90deg);} 5% {transform: scale(0.9) translateX(0px) translateY(-10px) rotate(90deg);} 15% {transform: scale(0.6) translateX(40vw) translateY(-10px) rotate(90deg);} 20% {transform: scale(0.6) translateX(35vw) translateY(-10px) rotate(90deg);} 25% {transform: scale(0.6) translateX(40vw) translateY(-10px) rotate(90deg);} 30% {transform: scale(0.6) translateX(35vw) translateY(-10px) rotate(90deg);} 35% {transform: scale(0.6) translateX(40vw) translateY(-10px) rotate(90deg);} 40% {transform: scale(0.6) translateX(35vw) translateY(-10px) rotate(90deg);} 45% {transform: scale(0.6) translateX(40vw) translateY(-10px) rotate(90deg);} 50% {transform: scale(0.6) translateX(40vw) translateY(-10px) rotate(270deg);} 75% {transform: scale(0.7) translateX(-20vw) translateY(-10px) rotate(270deg);} 80% {transform: scale(0.7) translateX(-20vw) translateY(-10px) rotate(90deg);} 92% {transform: scale(0.78) translateX(0px) translateY(-7px) rotate(87deg);} 100% {transform: scale(1) translateX(0px) rotate(0deg);} } @keyframes logoSlide { 0% {transform: translateX(0px) translateY(0px) rotate(0deg) scale(1);} 10% {transform: translateX(0px) translateY(52px) scale(0.9);} 20% {transform: translateX(0px) translateY(73px) scale(0.9);} 30% {transform: translateX(200px) translateY(73px) scale(0.9);} 40% {transform: translateX(-300px) translateY(73px) scale(0.9);} 50% {transform: translateX(400px) translateY(73px) scale(0.9);} 60% {transform: translateX(-500px) translateY(73px) scale(0.9);} 70% {transform: translateX(350px) translateY(73px) scale(0.9);} 85% {transform: translateX(-150px) translateY(73px) scale(0.9);} 93% {transform: translateX(0px) translateY(0px) scale(0.9);} 100% {transform: translateX(0px) translateY(0px) rotate(0deg) scale(1);} } @keyframes logoSpin { 0% {transform: translateX(0) scale(1) rotate(0deg);} 5% {transform: translateX(0) scale(0.5) rotate(0deg);} 20% {transform: translateX(0) scale(0.5) rotate(450deg);} 40% {transform: translateX(0) scale(0.5) rotate(450deg);} 60% {transform: translateX(0) scale(0.5) rotate(990deg);} 80% {transform: translateX(0) scale(0.5) rotate(990deg);} 95% {transform: translateX(0) scale(0.5) rotate(1080deg);} 100% {transform: translateX(0) scale(1) rotate(1080deg);} } @keyframes logoTriRotate { 0% {transform: scale(1) rotate(0deg);} 10% {transform: scale(0.7) rotate(0deg);} 20% {transform: scale(0.7) rotate(90deg);} 30% {transform: scale(0.7) rotate(180deg);} 40% {transform: scale(0.7) rotate(270deg);} 50% {transform: scale(0.7) rotate(360deg);} 60% {transform: scale(0.7) rotate(450deg);} 70% {transform: scale(0.7) rotate(540deg);} 80% {transform: scale(0.7) rotate(630deg);} 90% {transform: scale(0.7) rotate(720deg);} 100% {transform: scale(1) rotate(720deg);} } #PolivantageLogoOutline { stroke: none; stroke-width: 0px; fill: black; transform-origin: center; } #PolivantageLogo { width: 93px; height: auto; position: absolute; top: 0; left: 0; pointer-events: auto; cursor: pointer; margin-top: 10px; transition: all 0.3s ease; transform-origin: center; animation: logoFadeIn 5s ease forwards; } #PolivantageLogo:hover { scale: 1.1; }
Moving car indicator with jump
The following code adds a small car that drives along the forest line I added in my previous blog post. It follows the mouse on the horizontal axis. If clicked it drives autonomously off the grid and returns from the other side - forever trapped in the forest. If clicked while driving away, the car will perform a front flip. You can see how it works on my art page.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const subString = '/art'; const getURL = window.location.href; if (getURL.includes(subString)) { //mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { const footer = document.getElementById('footer-sections'); const bottomSeparator = footer.querySelector('.horizontalrule-block'); const carMainContainer = document.createElement('div'); carMainContainer.id = "carMainContainer"; carMainContainer.classList.add('carMainContainer'); const carContainer = document.createElement('div'); carContainer.id = "carContainer"; carContainer.classList.add('carContainer'); const forestCar = document.createElement('div'); forestCar.id = "forestCar"; forestCar.classList.add('forestCar'); const forestCarInnerContainer = document.createElement('div'); forestCarInnerContainer.id = "forestCarInnerContainer"; forestCarInnerContainer.classList.add('forestCarInnerContainer'); forestCarInnerContainer.appendChild(forestCar); carContainer.appendChild(forestCarInnerContainer); carMainContainer.appendChild(carContainer); bottomSeparator.appendChild(carMainContainer); //setup let debounceTimeout; let lastCarX = 0; let autodrive = false; let autodriveJump = false; const maxSpeed = 100; // pixels per second const debounceTime = 1 * 1000; const carMinDuration = 5; const carMaxDuration = 10; let zoomFactor = parseFloat(document.body.style.zoom) || 1; const containerRect = carMainContainer.getBoundingClientRect(); const carWidth = forestCarInnerContainer.offsetWidth; forestCarInnerContainer.style.transform = `translateX(${-carWidth}px)`; // Move car based on the calculated mouse position function moveCar(mouseX) { const containerRect = carMainContainer.getBoundingClientRect(); const containerLeft = containerRect.left; let carX = mouseX - containerLeft - carWidth / 2; if (carX < 0) { carX = 0; } else if (carX > containerRect.width - carWidth) { carX = containerRect.width - carWidth; } const distance = Math.abs(carX - lastCarX); lastCarX = carX; const duration = distance / maxSpeed; const cappedDuration = Math.min(Math.max(duration, carMinDuration), carMaxDuration); forestCarInnerContainer.style.transition = `transform ${cappedDuration}s ease-out`; forestCarInnerContainer.style.transform = `translateX(${carX}px)`; } //initiate movement on mouse move document.addEventListener('mousemove', function (event) { if (autodrive === false) { const mouseX = event.clientX; moveCar(mouseX); } }); //drive off and return forestCar.addEventListener('click', function () { if (autodrive === false) { autodrive = true; zoomFactor = parseFloat(document.body.style.zoom) || 1; const farRightX = (containerRect.width / zoomFactor) + carWidth; forestCarInnerContainer.style.transition = `transform ${carMaxDuration}s ease-out`; forestCarInnerContainer.style.transform = `translateX(${farRightX}px)`; setTimeout(() => { forestCarInnerContainer.style.transition = 'none'; forestCarInnerContainer.style.transform = `translateX(${-carWidth}px)`; lastCarX = -carWidth; autodrive = false; }, (carMaxDuration + 1) * 1000); } else { //jump and flip if click while autodrive if (autodriveJump === false) { autodriveJump = true; forestCar.style.transition = 'all 1s linear'; forestCar.classList.add('forestCarJump'); setTimeout(() => { forestCar.classList.remove('forestCarJump'); forestCar.style.transition = 'all 0s linear'; forestCar.style.transform = `translateY(${0}px) rotate(0deg)`; autodriveJump = false; }, 1 * 700); //match the css animation speed } } }); //exit } } }); </script>
CSS: @keyframes carJump { 50% {transform: translateY(-30px) rotate(180deg);} 100% {transform: translateY(0px) rotate(360deg);} } //CAR IN FOREST// #carMainContainer { position: absolute; display: flex; justify-content: center; align-items: center; pointer-events: none; width: 75%; height: 200px; transform: translateX(-50%); align-items: center; bottom: 50%; left: 50%; padding: 0; } #carContainer { position: absolute; pointer-events: none; width: 100%; height: 100%; top: 0; -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); } #forestCarInnerContainer { position: absolute; pointer-events: none; width: 56px !important; height: 28px !important; transition: all 1s linear; bottom: 0; left: 0; transform-origin: center; } #forestCar { position: absolute; pointer-events: auto; cursor: pointer; width: 56px !important; height: 28px !important; background: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/836bffeb-d10a-491a-815b-b1b0b215eaa0/beetleCarIcon1.png') no-repeat center center; background-size: contain; transition: all 5s ease; margin-bottom: 1px; z-index: 99999; bottom: 0; opacity: 1; left: 0; padding-bottom: 0px; } #forestCar.forestCarJump { animation: carJump 0.7s linear forwards; }
Procedural dynamic trees driven by local wind speed
The following code adds procedural, animated trees to the footer of my art section page. The trees are generated without the use of textures, or canvas, and make use of my weather data to react to wind speed and wind direction. This is reflected in their gentle, or not so gentle swaying. The trees of my little forest have three layers - the stem, primary branches, and secondary branches. The animation is procedural and driven by real time weather variables. It includes moving the thinner parts of the trees more than the thicker parts. Additionally the trees grow, fade, and re-grow every minute.
You may notice that some variable names refer to vines, this is a relic of how the project started. You can view the trees live in action on my art page, and see how I set the weather data variables to be retrieved in local storage in my blog post about weather effects.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const subString = '/art'; const getURL = window.location.href; if (getURL.includes(subString)) { //mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { const footer = document.getElementById('footer-sections'); const bottomSeparator = footer.querySelector('.horizontalrule-block'); const vinesMainContainer = document.createElement('div'); vinesMainContainer.id = "vinesMainContainer"; vinesMainContainer.classList.add('vinesMainContainer'); const vinesContainer = document.createElement('div'); vinesContainer.id = "vinesContainer"; vinesContainer.classList.add('vinesContainer'); vinesMainContainer.appendChild(vinesContainer); bottomSeparator.appendChild(vinesMainContainer); //initial setup const drawSpeed = 35; const vinesDuration = 60 * 1000; const maxVineCount = 15; const hardMaxVineCount = 25; let vineCount = 0; let minInterval = 30; let maxInterval = 500; const minStartThickness = 4; const maxStartThickness = 6; let randomInterval = 100; let lastVineStartX = []; const minDistanceBetweenVines = 50; const maxAttempts = minDistanceBetweenVines / 2; const minVineHeight = 0.2; const maxVineHeight = 0.55; const splitOccurStart = 0.1; const splitOccurEnd = 0.8; let windSpeed = 15; let windDirection = 360; const storedWeatherData = localStorage.getItem('weatherData'); if (storedWeatherData) { const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; windDirection = weather.winddirection; windSpeed = weather.windspeed; } //swaying animation function applyWindEffect(svg) { const vineSegments = svg.querySelectorAll('polyline'); // Get all vine segments const mainVineSegments = Array.from(vineSegments).filter(segment => segment.classList.contains('main-vine')); // Filter for main vine segments const baseSegment = mainVineSegments[0]; // The base of the main vine const basePoint = baseSegment.getAttribute('points').split(' ')[0].split(',').map(Number); const maxRotation = windSpeed * 0.00008; // Max swaying angle let swayAngle = 0; const swayDirection = windDirection >= 180 ? -1 : 1; const swayIncrement = 0.07; // Adjust this value as needed function sway() { swayAngle += 0.03; const rotationAngle = Math.sin(swayAngle) * maxRotation; for (let i = 0; i < mainVineSegments.length; i++) { const segment = mainVineSegments[i]; const group = segment.closest('g'); const subgroups = group ? group.querySelectorAll('g') : []; let points = segment.getAttribute('points').split(' ').map(point => point.split(',').map(Number)); points = points.map((point, index) => { const [x, y] = point; const dx = x - basePoint[0]; const dy = y - basePoint[1]; const currentRotation = rotationAngle * (1 + swayIncrement * i); const newX = basePoint[0] + (dx * Math.cos(currentRotation * swayDirection) - dy * Math.sin(currentRotation * swayDirection)); const newY = basePoint[1] + (dx * Math.sin(currentRotation * swayDirection) + dy * Math.cos(currentRotation)); return [newX, newY]; }); if (i > 0) { points[0] = mainVineSegments[i - 1].getAttribute('points').split(' ').map(point => point.split(',').map(Number)).slice(-1)[0]; } else { points[0] = basePoint; } segment.setAttribute('points', points.map(point => point.join(',')).join(' ')); //move branches subgroups.forEach(subgroup => { const subgroupPolylines = subgroup.querySelectorAll('polyline'); subgroupPolylines.forEach(polyline => { const subgroupPoints = polyline.getAttribute('points') ? polyline.getAttribute('points').split(' ').map(point => point.split(',').map(Number)) : []; const isSecondarySplit = polyline.classList.contains('secondary-split'); const newSubgroupPoints = subgroupPoints.map((point) => { const [x, y] = point; const dx = x - basePoint[0]; const dy = y - basePoint[1]; // Use different logic for secondary splits const currentRotation = isSecondarySplit ? rotationAngle * swayDirection : rotationAngle * (1 + swayIncrement * i); const newX = basePoint[0] + (dx * Math.cos(currentRotation) - dy * Math.sin(currentRotation)); const newY = basePoint[1] + (dx * Math.sin(currentRotation) + dy * Math.cos(currentRotation)); return [newX, newY]; }); if (newSubgroupPoints.length > 0) { polyline.setAttribute('points', newSubgroupPoints.map(point => point.join(',')).join(' ')); } }); }); //end branches move } requestAnimationFrame(sway); } sway(); } //create the trees function createVinesSVG(id) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('id', id); svg.setAttribute('class', 'vine'); vinesContainer.appendChild(svg); return svg; } function createVines() { const uniqueId = `vine-${Date.now()}`; const svg = createVinesSVG(uniqueId); let startX; let attempts = 0; // Try to find a valid position for the vine's startX do { startX = Math.random() * svg.clientWidth; attempts++; } while (attempts < maxAttempts && lastVineStartX.some(prevX => Math.abs(prevX - startX) < minDistanceBetweenVines)); let startY = svg.clientHeight; let maxHeight = Math.random() * (svg.clientHeight * maxVineHeight) + (svg.clientHeight * minVineHeight); let segments = Math.floor(maxHeight / (svg.clientHeight / 40)); const startThickness = Math.random() * (maxStartThickness - minStartThickness) + minStartThickness; let x = startX; let y = startY; lastVineStartX.push(startX); if (lastVineStartX.length > maxVineCount - 1) { lastVineStartX.shift(); } // Draw segments over time let currentSegment = 0; let splitCount = 0; let drawInterval = setInterval(() => { if (currentSegment < segments) { let polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('fill', 'none'); let thickness = Math.max(startThickness - (currentSegment * (maxStartThickness / segments)), maxStartThickness / 3.3); polyline.setAttribute('stroke-width', `${thickness}px`); polyline.setAttribute('class', 'main-vine'); let mainGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); mainGroup.appendChild(polyline); svg.appendChild(mainGroup); let points = `${x},${y}`; y -= (maxHeight / segments); x += (Math.random() - 0.5) * 3; // Random horizontal displacement points += ` ${x},${y}`; polyline.setAttribute('points', points); // Determine if a split should occur let splitStartThreshold = Math.floor(segments * splitOccurStart); let splitEndThreshold = Math.floor(segments * splitOccurEnd); let splitDirection = Math.random() < 0.5 ? 'left' : 'right'; if (currentSegment > splitStartThreshold && currentSegment < splitEndThreshold && currentSegment % 2 === 0 && Math.random() > 0.45) { drawSplit({ x, y, direction: splitDirection }, svg, splitCount, thickness, mainGroup); splitCount++; } currentSegment++; } else { clearInterval(drawInterval); setTimeout(() => { applyWindEffect(svg); }, 3000); setTimeout(() => { vinesContainer.removeChild(svg); vineCount--; }, vinesDuration); } }, drawSpeed); } // Splitting for primary branches function drawSplit(split, svg, splitCount, stemThickness, mainGroup) { let x = split.x; let y = split.y; let splitSegments = 4 + Math.floor(Math.random() * 3); let maxSplitLength = (svg.clientHeight / 30) * (4 - splitCount); let minSplitLength = (svg.clientHeight / 30) * 2; let splitHeight = Math.random() * (maxSplitLength - minSplitLength) + minSplitLength; let maxHorizontalDisplacement = Math.tan(Math.PI / 4.5) * splitHeight; let currentSplitSegment = 0; let thickness = stemThickness / 1.3; //starting branch thickness let splitStartThreshold = Math.floor(splitSegments * 0.2); let splitEndThreshold = Math.floor(splitSegments * 0.5); let splitGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); mainGroup.appendChild(splitGroup); let splitDrawInterval = setInterval(() => { if (currentSplitSegment < splitSegments) { let points = `${x},${y}`; let horizontalDisplacement = (Math.random() - 0.5) * maxHorizontalDisplacement; if (split.direction === 'left') { x -= Math.abs(horizontalDisplacement); } else { x += Math.abs(horizontalDisplacement); } y -= splitHeight / splitSegments; points += ` ${x},${y}`; let splitPolyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); splitPolyline.setAttribute('fill', 'none'); splitPolyline.setAttribute('points', points); splitPolyline.setAttribute('stroke-width', `${thickness}px`); splitPolyline.setAttribute('class', 'primary-split'); splitGroup.appendChild(splitPolyline); //svg.appendChild(splitPolyline); thickness = Math.max(thickness - ((stemThickness - 1) / splitSegments), 1); // Randomly trigger secondary split if (currentSplitSegment >= splitStartThreshold && currentSplitSegment <= splitEndThreshold && Math.random() > 0.43) { drawSecondarySplit({ x, y, direction: Math.random() < 0.5 ? 'left' : 'right' }, svg, splitCount + 1, thickness, splitGroup); } currentSplitSegment++; } else { clearInterval(splitDrawInterval); } }, drawSpeed * 2); } // secondary branches function drawSecondarySplit(split, svg, splitCount, parentThickness, splitGroup) { let x = split.x; let y = split.y; let splitSegments = 2; let maxSplitLength = (svg.clientHeight / 50) * (3 - splitCount); let minSplitLength = (svg.clientHeight / 60) * 1; let splitHeight = Math.random() * (maxSplitLength - minSplitLength) + minSplitLength; let maxHorizontalDisplacement = Math.tan(Math.PI / 8) * splitHeight; let currentSplitSegment = 0; let thickness = parentThickness / 1.7; const secondaryGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); splitGroup.appendChild(secondaryGroup); let splitDrawInterval = setInterval(() => { if (currentSplitSegment < splitSegments) { let points = `${x},${y}`; let horizontalDisplacement = (Math.random() - 0.5) * maxHorizontalDisplacement; if (split.direction === 'left') { x -= Math.abs(horizontalDisplacement); } else { x += Math.abs(horizontalDisplacement); } y -= splitHeight / splitSegments; points += ` ${x},${y}`; let secondaryPolyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); secondaryPolyline.setAttribute('fill', 'none'); secondaryPolyline.setAttribute('points', points); secondaryPolyline.setAttribute('stroke-width', `${thickness}px`); secondaryPolyline.setAttribute('class', 'secondary-split'); secondaryGroup.appendChild(secondaryPolyline); currentSplitSegment++; } else { clearInterval(splitDrawInterval); } }, drawSpeed * 4); } //animate function triggerRandomVines() { if (vineCount < maxVineCount) { vineCount++; createVines(); } randomInterval = Math.random() * (maxInterval - minInterval) + minInterval; setTimeout(triggerRandomVines, randomInterval); } setTimeout(triggerRandomVines, 2000); //exit } } }); </script>
CSS: @keyframes vineFade { 0% {opacity: 1; stroke: #000000; filter: blur(0.0px)} 95% {opacity: 1; stroke: #000000; filter: blur(0.0px)} 100% {opacity: 0; filter: blur(6px); stroke: #464646;} } //VINES TREES FOREST// #vinesMainContainer { position: absolute; display: flex; justify-content: center; align-items: center; pointer-events: none; width: 75%; height: 200px; transform: translateX(-50%); align-items: center; bottom: 50%; left: 50%; padding: 0; } #vinesContainer { position: absolute; pointer-events: none; width: 100%; height: 100%; top: 0; -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); } .vine { position: absolute; pointer-events: none; //filter: blur(0.0px); stroke: #000000; width: 100%; height: 100%; top: 0%; stroke-linecap: round; stroke-linejoin: round; animation: vineFade 59s forwards; -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 7%, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 60%); mask-image: linear-gradient(0deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 7%, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 60%); }
Procedural dynamic Lightning effect
The following code implements procedural lightning effects to enhance stormy weather appearance. If you haven’t followed my previous post about weather effects, you should check it out to see how I set the weather type variable in session storage. I retrieve this variable and depending on its value (‘stormy’ for example), I create the lightning intermittently. The frequency is randomized within a range and the lightning is drawn dynamically, with splitting off branches. CSS is used to make it even more realistic. During rainy and foggy weather there is also a small chance that the lightning will be triggered occasionally. The lightning is generated on the fly, never repeating. SVG elements are used to draw lines between generated points. There are no textures involved, it is pure code.
For illustration purposes I enabled the lightning to strike on every mouse click on this page. It will only appear if you haven’t toggled the weather off.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', async function () { const sectionBG = document.querySelector('.section-background'); const lightningMainContainer = document.createElement('div'); lightningMainContainer.id = "lightningMainContainer"; lightningMainContainer.classList.add('lightningMainContainer'); const lightningContainer = document.createElement('div'); lightningContainer.id = "lightningContainer"; lightningContainer.classList.add('lightningContainer'); lightningMainContainer.appendChild(lightningContainer); sectionBG.appendChild(lightningMainContainer); const drawSpeed = 0.4; // Speed of drawing in milliseconds const lightningDuration = 5000; let minInterval = 500; let maxInterval = 20000; function createLightningSVG(id) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('id', id); svg.setAttribute('class', 'lightningBolt'); //svg.style.position = 'absolute'; svg.style.top = '0'; //svg.style.filter = 'blur(1px)'; lightningContainer.appendChild(svg); return svg; } function createLightning() { const uniqueId = `lightning-${Date.now()}`; const svg = createLightningSVG(uniqueId); let startX = Math.random() * svg.clientWidth; // Random start X position let startY = 0; let segments = 50 + Math.floor(Math.random() * 5); //max segments of main bolt let x = startX; let y = startY; // Draw segments over time let currentSegment = 0; // Track the current segment being drawn let drawInterval = setInterval(() => { if (currentSegment < segments) { let polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('fill', 'none'); // Calculate the thickness for the new segment let thickness = Math.max(4 - (currentSegment * (5 / segments)), 0.2); polyline.setAttribute('stroke-width', `${thickness}px`); svg.appendChild(polyline); // Create points for the current segment let points = `${x},${y}`; x += (Math.random() - 0.5) * 30; // Random horizontal displacement y += (svg.clientHeight / segments); // Move downward points += ` ${x},${y}`; polyline.setAttribute('points', points); // Determine if a split should occur if (y > svg.clientHeight * 0.3 && y < svg.clientHeight * 0.7 && currentSegment % 3 === 0 && Math.random() > 0.5) { drawSplit({ x, y, direction: Math.random() < 0.5 ? 'left' : 'right' }, svg); } currentSegment++; } else { clearInterval(drawInterval); setTimeout(() => { lightningContainer.removeChild(svg); }, lightningDuration); } }, drawSpeed); } //splitting function drawSplit(split, svg) { let points = `${split.x},${split.y}`; let splitSegments = 13 + Math.floor(Math.random() * 3); // Fewer segments for splits let x = split.x; let y = split.y; // Calculate maximum split length let maxSplitLength = (svg.clientHeight / 30) * 4; let minSplitLength = (svg.clientHeight / 30) * 2; let splitHeight = Math.random() * (maxSplitLength - minSplitLength) + minSplitLength; let maxHorizontalDisplacement = Math.tan(Math.PI / 7) * splitHeight; //angle adjustment let splitPolyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); splitPolyline.setAttribute('fill', 'none'); svg.setAttribute('class', 'lightningBolt'); svg.appendChild(splitPolyline); let currentSplitSegment = 0; let splitDrawInterval = setInterval(() => { if (currentSplitSegment < splitSegments) { let horizontalDisplacement = (Math.random() - 0.5) * maxHorizontalDisplacement; // Adjust direction based on the split direction if (split.direction === 'left') { x -= Math.abs(horizontalDisplacement); } else { x += Math.abs(horizontalDisplacement); } y += splitHeight / splitSegments; points += ` ${x},${y}`; splitPolyline.setAttribute('points', points); let thickness = Math.max(3 - (currentSplitSegment * (5 / splitSegments)), 1); splitPolyline.setAttribute('stroke-width', `${thickness}px`); currentSplitSegment++; } else { clearInterval(splitDrawInterval); } }, drawSpeed); } // Trigger the lightning animation function triggerRandomLightning() { createLightning(); const randomInterval = Math.random() * (maxInterval - minInterval) + minInterval; setTimeout(triggerRandomLightning, randomInterval); } //where and how we want lightning let weatherType = sessionStorage.getItem('weatherType'); const shopString = '/shop'; const secretShopString = '/sanctumshop'; const productPageString = 'shop/p/'; const artString = '/art'; const loginString = '/login'; const signupString = '/signup'; const dossierString = '/accountdossier'; const cartPageString = '/cart'; const userGalleryString = '/usergallery'; const devlogString = '/developmentlog'; const devlogListString = '/devlog'; const getURL = window.location.href; function triggerLightning() { weatherType = sessionStorage.getItem('weatherType'); const gallerySection = document.querySelector('.gallery-masonry'); if (getURL.includes(loginString) || getURL.includes(signupString) || getURL.includes(dossierString) || getURL.includes(cartPageString) || getURL.includes(userGalleryString) || getURL.includes(productPageString) || (gallerySection && !getURL.includes(artString)) || (weatherType === 'sunny')) { // lightningMainContainer.remove(); return; } if (weatherType === 'stormy') { triggerRandomLightning(); document.addEventListener('click', function() { const randomChance = Math.random(); if (randomChance <= 0.25) { createLightning(); } }); } else if (weatherType === 'cloudy' || weatherType === 'rainy' || weatherType === 'foggy') { minInterval = 20000; maxInterval = 120000; triggerRandomLightning(); } } triggerLightning(); //exit }); </script>
CSS: @keyframes lightningFade { 0% {opacity: 1; stroke: #ffffff; transform: scale(1);} 10% {stroke: #ffffff;} 55% {opacity: 0.8; filter: blur(1px); stroke: var(--color2);} 60% {stroke: rgba(255,255,255,0.4);} 65% {stroke: var(--color2); filter: blur(3px);} 70% {stroke: rgba(255,255,255,0.4); filter: blur(0px);} 100% {opacity: 0; filter: blur(7px); stroke: #ffffff; transform: scale(0.995);} } //LIGHTNING// #lightningMainContainer { display: flex; justify-content: center; align-items: center; pointer-events: none; width: 100%; height: 100%; top: 0; left: 0; } #lightningContainer { position: absolute; pointer-events: none; max-width: 2200px; width: 100%; height: 100%; max-height: 100vh; top: 0; -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); } .lightningBolt { position: absolute; pointer-events: none; filter: blur(0.9px); animation: lightningFade 3s forwards; stroke: #ffffff; }
Proud of it, it is one of the coolest looking effects I have created since I started learning Javascript, outside of a game engine.
horoscopes, solar flares, geomagnetic storms, and a dinosaur
The following implementation allows visitors to read their daily horoscope and to get actual information on recent solar flare and geomagnetic storm intensities. This is achieved by inquiring a few APIs, one of which is a NASA endpoint called DONKI (Space Weather Database Of Notifications, Knowledge, Information). The other is the only seemingly free astrology endpoint I could get to work, and only by writing a back-end function in my firebase account to bypass CORS limitations. NASA provides up to date information on a bunch of things, but for now we are only inquiring about solar flares (over the last 24 hours) and geomagnetic storms (over the last month).
Solar flares can lead to geomagnetic storms, which in turn can disrupt power systems and cause mood alteration in people and animals. The horoscope endpoint only works through a CORS proxy function, which makes sure the request has correct headers and authentication credentials. This is because squarespace does not allow you to modify how CORS requests are handled. All of the used APIs are free, though I haven’t tested them on a big scale. After getting the data from the NASA APIs there is some time and date conversion going on to present the information in your local time. There is also parsing of intensity of the solar flares and geomagnetic storms, into a 1-10 scale, which then assigns the severity of the event as a word, for user friendliness. The horoscope API is not aligned with your local time and only accepts today and tomorrow as relevant dates. As such, I first query the today’s horoscope and if the date is yesterday according to your local time, a “tomorrow’s” horoscope is fetched, resulting in the correct one, according to your time zone. I get your time zone information from yet another API which takes the IP address as the query.
There is also a dinosaur which acts as a button to toggle the overlay presenting the information. The dinosaur changes face expressions based on the most recent solar flare intensity. The code below is functional, but you will have to get your free API key from https://api.nasa.gov/. All the HTML structure is generated dynamically in javascript. To see the code working, you can navigate to the origin page, contact page, or your account page (requires logging in), then click on the dinosaur which is hanging out on top of the footer.
Since we have a dinosaur, i found it impossible to not add an asteroid. The asteroid just floats about from left to right and never impacts anything for a change. It floats up and away if you close the tab, and comes back down when the tab is re-opened. Since the last update I also show a hidden message. It consists of a random quote pertaining to mysticism and science. It is situated under the header and is only visible if you scroll down just enough to hide the header, but still see the top of the tab.
HTML:
JAVASCRIPT: document.addEventListener('DOMContentLoaded', async function () { //pages where we show dinosaur const subString = '/useraccount'; const subStringTwo = '/history'; const subStringThree = '/contact'; const getURL = window.location.href; if (getURL.includes(subString) || getURL.includes(subStringTwo) || getURL.includes(subStringThree)) { //mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { //main setup let gstInfo = 'Geomagnetic Storm Activity:'; let solarInfo = 'Solar Flare Activity:'; let lastGst = 'Last Geomagnetic Storm:'; let lastSolar = 'Last Solar Flare:'; const severityLevels = { 0: 'Negligible', 1: 'Very Low', 2: 'Low', 3: 'Noticeable', 4: 'Moderate', 5: 'High', 6: 'Very High', 7: 'Severe', 8: 'Extreme', 9: 'Catastrophic', 10: 'Cataclysmic' }; const classTypeSeverity = { 'A': 0, 'B': 1, 'C': 2, 'M': 3, 'X': 4 }; const zodiacSigns = [ 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces' ]; //the number of quptes has been greatly redacted for this blog, there are over 40. const occultismQuotes = [ "The mystic is not the one who sees visions, but the one who sees reality.", "What we perceive as reality is only a veil over the true nature of existence." ]; //time management const refreshInterval = 6 * 60 * 60 * 1000; //when is api data outdated const timeOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }; const today = new Date(); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); const oneMonthAgo = new Date(today); oneMonthAgo.setMonth(today.getMonth() - 1); let startDateGst = oneMonthAgo.toISOString().split('T')[0]; let startDateSolar = yesterday.toISOString().split('T')[0]; const endDate = today.toISOString().split('T')[0]; let offsetMilliseconds = 0; //get geolocation data for correct time fetch('https://ipapi.co/json/') .then(response => response.json()) .then(data => { const utcOffset = data.utc_offset; const offsetHours = parseInt(utcOffset.slice(0, 3)); offsetMilliseconds = offsetHours * 60 * 60 * 1000; }) .catch(error => { }); let gst; let solar; let severityString = 'Negligible'; //severity to text conversion function getSolarFlareSeverity(classType) { const letterPart = classType.match(/[A-Z]/)[0]; let intensityLevel = classTypeSeverity[letterPart] !== undefined ? classTypeSeverity[letterPart] : 0; const numberPartMatch = classType.match(/\d+/); const numberPart = numberPartMatch ? parseFloat(numberPartMatch[0]) : 0; const percent = (intensityLevel / 5) * 100; if (numberPart > 0) { const additionalSeverity = ((numberPart / 9) * 100) / 10; intensityLevel = (percent + additionalSeverity) / 11; } intensityLevel = Math.round(intensityLevel); const severity = severityLevels[intensityLevel] || 'Unknown Severity'; return severity; } //act on the begotten nasa data async function displayData() { gstInfo = 'Geomagnetic Storm Activity:'; solarInfo = 'Solar Flare Activity:'; lastGst = 'Last Geomagnetic Storm:'; lastSolar = 'Last Solar Flare:'; let index = 1; let storedGSTData = localStorage.getItem('gstData'); if (storedGSTData) { const gstData = JSON.parse(storedGSTData); const gstTimestamp = new Date(gstData.timestamp); if (today - gstTimestamp > refreshInterval) { await fetchGSTData(); } else { gst = gstData.data; gst.allKpIndex.forEach(observation => { const eventTime = observation.observedTime; const eventSeverity = observation.kpIndex; const roundedSeverity = Math.round(eventSeverity); let severityDescription = severityLevels[roundedSeverity] || 'Unknown Severity'; const utcDate = new Date(eventTime); const localDate = new Date(utcDate.getTime() + offsetMilliseconds); let localTimeString = localDate.toLocaleString(undefined, timeOptions); gstInfo += `<br>🗲 ${index}: ${localTimeString} - ${severityDescription}.`; index++; if (index === gst.allKpIndex.length + 1) { lastGst += ` ${localTimeString} - Severity: ${severityDescription}.`; } }); } } else { await fetchGSTData(); } index = 1; let storedSolarData = localStorage.getItem('solarData'); if (storedSolarData) { const solarData = JSON.parse(storedSolarData); const solarTimestamp = new Date(solarData.timestamp); if (today - solarTimestamp > refreshInterval) { await fetchSolarData(); } else { solar = solarData.data; solar.forEach(observation => { const eventTime = observation.peakTime; const classType = observation.classType; const severity = getSolarFlareSeverity(classType); const utcDate = new Date(eventTime); const localDate = new Date(utcDate.getTime() + offsetMilliseconds); let localTimeString = localDate.toLocaleString(undefined, timeOptions); solarInfo += `<br>✹ ${index}: ${localTimeString} - ${severity}.`; index++; if (index === solar.length + 1) { lastSolar += ` Observed: ${localTimeString} - Severity: ${severity}.`; } }); } } else { await fetchSolarData(); } } //get geomagnetic storm activity data async function fetchGSTData() { const response = await fetch(`https://api.nasa.gov/DONKI/GST?startDate=${startDateGst}&endDate=${endDate}&api_key=FREENASAAPIKEY`); const data = await response.json(); if (data.length > 0) { gst = data[0]; const timestamp = new Date().toISOString(); const gstDataToStore = { data: gst, timestamp: timestamp }; localStorage.setItem('gstData', JSON.stringify(gstDataToStore)); } else { gst = []; localStorage.setItem('gstData', JSON.stringify({ data: gst, timestamp: timestamp })); } } //get solar flare data async function fetchSolarData() { const response = await fetch(`https://api.nasa.gov/DONKI/FLR?startDate=${startDateSolar}&endDate=${endDate}&api_key=FREENASAAPIKEY`); const data = await response.json(); if (data && data.length > 0) { solar = data; const timestamp = new Date().toISOString(); const solarDataToStore = { data: solar, timestamp: timestamp }; localStorage.setItem('solarData', JSON.stringify(solarDataToStore)); } else { solar = []; localStorage.setItem('solarData', JSON.stringify({ data: solar, timestamp: timestamp })); } } await displayData(); function clearDinoEmotions(){ const dino = document.querySelector('#nasaDino'); if (dino) { dino.classList.remove('happy'); dino.classList.remove('sad'); dino.classList.remove('stupid'); dino.classList.remove('angry'); dino.classList.remove('shy'); } } //horoscope call and makeup function writeHoroscope(sign, data) { const horoscopeTextDiv = document.getElementById('horoscopeText'); if (horoscopeTextDiv) { horoscopeTextDiv.innerHTML = ` <h3 id="horoscopeDataTitle">${sign}</h3> <p id="horoscopeDataDate">${data.data.date}</p> <div id="horoscopeDataTextContainer"><p id="horoscopeDataText">${data.data.horoscope_data}</p></div>`; } } const getHoroscope = async (sign) => { let formattedDate = 'TODAY'; const proxyUrl = 'https://us-central1-MYFIREBASEAPPID.cloudfunctions.net/corsProxy'; const targetUrl = `https://horoscope-app-api.vercel.app/api/v1/get-horoscope/daily?sign=${sign}&day=${formattedDate}`; const today = new Date(); try { const response = await fetch(`${proxyUrl}?url=${encodeURIComponent(targetUrl)}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); const horoscopeDate = new Date(data.data.date); if ( horoscopeDate.getDate() === today.getDate() && horoscopeDate.getMonth() === today.getMonth() && horoscopeDate.getFullYear() === today.getFullYear() ) { writeHoroscope(sign, data); } else { formattedDate = 'TOMORROW'; const newTargetUrl = `https://horoscope-app-api.vercel.app/api/v1/get-horoscope/daily?sign=${sign}&day=${formattedDate}`; const tomorrowResponse = await fetch(`${proxyUrl}?url=${encodeURIComponent(newTargetUrl)}`); if (!tomorrowResponse.ok) { throw new Error(`HTTP error! status: ${tomorrowResponse.status}`); } const tomorrowData = await tomorrowResponse.json(); writeHoroscope(sign, tomorrowData); } } catch (error) { //console.error('Error fetching horoscope:', error); } }; //create a moody dino mascot function emotionalDinosaurDisplay() { clearDinoEmotions(); const dino = document.createElement('div'); dino.id = "nasaDino"; dino.classList.add('nasaDino'); const dinoContainer = document.createElement('div'); dinoContainer.id = "nasaDinoContainer"; const dinoMainContainer = document.createElement('div'); dinoMainContainer.id = "nasaDinoMainContainer"; const footer = document.getElementById('footer-sections'); const bottomSeparator = footer.querySelector('.horizontalrule-block'); dinoContainer.appendChild(dino); dinoMainContainer.appendChild(dinoContainer); bottomSeparator.appendChild(dinoMainContainer); severityString = lastSolar.split('Severity: ')[1].trim(); if (severityString === 'Negligible' || severityString === 'Very Low') { dino.classList.add('happy'); } else if (severityString === 'Noticeable' || severityString === 'Moderate') { dino.classList.add('shy'); } else if (severityString === 'High' || severityString === 'Very High') { clearDinoEmotions(); } else if (severityString === 'Severe') { dino.classList.add('stupid'); } else if (severityString === 'Extreme' || severityString === 'Catastrophic' || severityString === 'Cataclysmic') { dino.classList.add('angry'); } else { clearDinoEmotions(); } //structure and embed the visual data overlay let nasaInfoShow = false; const nasaInfo = document.createElement('div'); nasaInfo.id = "nasaInfo"; nasaInfo.classList.add('nasaInfo'); const nasaInfoContainer = document.createElement('div'); nasaInfoContainer.id = "nasaInfoContainer"; const sectionBG = document.querySelector('.section-background'); nasaInfoContainer.appendChild(nasaInfo); sectionBG.appendChild(nasaInfoContainer); document.body.appendChild(nasaInfoContainer); const horoscopeContainer = document.createElement('div'); horoscopeContainer.id = "horoscopeContainer"; horoscopeContainer.classList.add('horoscopeContainer'); horoscopeContainer.innerHTML = ''; const horoscopeSignContainer = document.createElement('div'); horoscopeSignContainer.id = "horoscopeSignContainer"; horoscopeSignContainer.classList.add('horoscopeSignContainer'); horoscopeSignContainer.innerHTML = ''; zodiacSigns.forEach(sign => { const iconDiv = document.createElement('div'); iconDiv.classList.add('zodiac-icon'); iconDiv.id = sign.toLowerCase() + 'Icon'; innerHTML = ''; iconDiv.addEventListener('click', () => { getHoroscope(sign); }); horoscopeSignContainer.appendChild(iconDiv); }); horoscopeContainer.appendChild(horoscopeSignContainer); const horoscopeTextDiv = document.createElement('div'); horoscopeTextDiv.id = 'horoscopeText'; horoscopeTextDiv.classList.add('horoscopeText'); horoscopeContainer.appendChild(horoscopeTextDiv); nasaInfo.innerHTML = ''; const nasaShadowDiv = document.createElement('div'); nasaShadowDiv.id = 'nasaShadowDiv'; nasaShadowDiv.classList.add('nasaShadowDiv'); const nasaShadowDivContainer = document.createElement('div'); nasaShadowDivContainer.id = 'nasaShadowDivContainer'; nasaShadowDivContainer.classList.add('nasaShadowDivContainer'); nasaShadowDivContainer.appendChild(nasaShadowDiv); const hiddenHeaderMsg = document.createElement('div'); hiddenHeaderMsg.id = 'hiddenHeaderMsg'; hiddenHeaderMsg.classList.add('hiddenHeaderMsg'); hiddenHeaderMsg.innerHTML = `<h3>Strangeness is more fictional than some times</h3>`; nasaShadowDiv.appendChild(hiddenHeaderMsg); nasaInfo.appendChild(nasaShadowDivContainer); nasaInfo.appendChild(horoscopeContainer); const nasaInfoDiv = document.createElement('div'); nasaInfoDiv.id = 'nasaInfoDiv'; nasaInfoDiv.classList.add('nasaInfoDiv'); const solarInfoDiv = document.createElement('div'); solarInfoDiv.id = 'solarInfoDiv'; solarInfoDiv.classList.add('solarInfoDiv'); solarInfoDiv.innerHTML = solarInfo; const gstInfoDiv = document.createElement('div'); gstInfoDiv.id = 'gstInfoDiv'; gstInfoDiv.classList.add('gstInfoDiv'); gstInfoDiv.innerHTML = gstInfo; nasaInfoDiv.appendChild(gstInfoDiv); nasaInfoDiv.appendChild(solarInfoDiv); nasaInfo.appendChild(nasaInfoDiv); const asteroidContainer = document.createElement('div'); asteroidContainer.id = 'asteroidContainer'; asteroidContainer.classList.add('asteroidContainer'); const asteroid = document.createElement('div'); asteroid.id = 'asteroid'; asteroid.classList.add('asteroid'); const asteroidInnerContainer = document.createElement('div'); asteroidInnerContainer.id = 'asteroidInnerContainer'; asteroidInnerContainer.classList.add('asteroidInnerContainer'); asteroidContainer.appendChild(asteroidInnerContainer); asteroidInnerContainer.appendChild(asteroid); nasaInfo.appendChild(asteroidContainer); //show overlay dino.addEventListener('click', function () { if (nasaInfoShow === false){ nasaInfo.classList.add('showInfo'); dino.classList.add('showInfo'); asteroidContainer.classList.add('showInfo'); nasaInfoShow = true; let randomQuote = occultismQuotes[Math.floor(Math.random() * occultismQuotes.length)]; hiddenHeaderMsg.innerHTML = `<h3>${randomQuote}</h3>`; } else { nasaInfo.classList.remove('showInfo'); dino.classList.remove('showInfo'); asteroidContainer.classList.remove('showInfo'); nasaInfoShow = false; } }); } //initiate await displayData(); emotionalDinosaurDisplay(); } } });
CSS: #hiddenHeaderMsg { position: absolute; top: 0px; margin-top: 30px; height: auto; width: auto; opacity: 0.5; } #hiddenHeaderMsg h3 { color: var(--color2); font-size: 20px; } #nasaShadowDivContainer { display: flex; justify-content: center; top: 0; z-index: 10; margin-top: -200px; } #nasaShadowDiv { display: flex; justify-content: center; top: 0; height: 210px; width: 94%; margin-bottom: 10px; background: linear-gradient(0deg, rgba(0,0,0,0) 10%, rgba(0,0,0,0.5) 100%); mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, rgba(0,0,0,0) 100%); pointer-events: none; } //asteroid #asteroidContainer { position: absolute; display: flex; justify-content: center; top: -200px; bottom: 0; z-index: 10; pointer-events: none; transition: all 5s ease; } #asteroidContainer.showInfo { display: flex; justify-content: center; bottom: 0; z-index: 10; top: 75%; pointer-events: none; } #asteroidInnerContainer { position: absolute; left: 0; display: flex; justify-content: center; animation: moveLeftToRight 90s linear infinite; } #asteroidInnerContainer::before { content: ''; position: absolute; width: 550px; height: 90px; margin-left: -530px; bottom: 0; margin-bottom: 10px; background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(255,255,255,1) 95%, rgba(0,0,0,0) 100%); left: 0; opacity: 0.2; mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%); } #asteroid { pointer-events: none; position: absolute; width: 110px; height: 110px; bottom: 0; background-repeat: no-repeat; background-position: center; background-size: contain; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/fd29d9f0-008c-46a8-a008-ee28671dea2b/AsteroidColoredShadowedIcon1.png'); animation: rotator 55s infinite linear; } //horoscope #horoscopeContainer { width: 100% height: 100%; display: flex; justify-content: center; flex-direction: column; align-items: center; } #horoscopeSignContainer { display: flex; justify-content: center; } #horoscopeText { display: flex; flex-direction: column; align-items: center; } #horoscopeDataTextContainer { max-width: 1300px; } #horoscopeDataText { font-size: 22px; letter-spacing: 1.1px; text-align: justify; } #horoscopeDataTitle { text-align: center; margin: 0; margin-top: 50px; font-size: 2.4em; } #horoscopeDataDate { font-weight: bold; text-align: center; margin: 0; letter-spacing: 1.4px; text-transform: uppercase; font-size: 1.5em; margin-top: 10px; } .zodiac-icon { width: 90px; height: 90px; margin-left: 10px; margin-right: 10px; cursor: pointer; transition: all 0.6s ease; background-repeat: no-repeat; background-position: center; background-size: contain; } .zodiac-icon:hover { transition: all 0.2s ease; scale: 1.1; } #ariesIcon { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/d876fc39-70c6-4aaf-8380-767affa970f2/Horo_AriesIcon1.png'); } #taurusIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/a47a3236-f3cc-4fc9-bcfd-43596d69e8d8/Horo_TaurusIcon1.png'); } #geminiIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/c3184264-a0e0-4323-9ba0-cdada7f354aa/Horo_GeminiIcon1.png'); } #cancerIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/cc603817-9fd4-4c7c-8f81-98b475552b59/Horo_CancerIcon1.png'); } #leoIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/059693d5-72d5-46d0-a9ad-d5cf9bec2858/Horo_LeoIcon1.png'); } #virgoIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/e647c46b-8dca-4775-bc85-7fa73cf558cc/Horo_VirgoIcon1.png'); } #libraIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/d9c94d54-315f-45c9-b694-e1704c408bf5/Horo_LibraIcon1.png'); } #scorpioIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/472379ef-91eb-41f6-ad33-b9c5a4718589/Horo_ScorpioIcon1.png'); } #sagittariusIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/b7481c13-e070-4ea2-8c16-3c1abc23435d/Horo_SaggitariusIcon1.png'); } #capricornIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/ca2598ce-0302-43e9-b10f-c3ce785b6549/Horo_CapricornIcon1.png'); } #aquariusIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/42ccd070-81f2-4ec6-aac6-4975868d0f3e/Horo_AquariusIcon1.png'); } #piscesIcon{ background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/74fb822e-fa02-4614-a51c-65272f70c5c1/Horo_PicesIcon1.png'); } //solar flare and storm #nasaInfoDiv { display: flex; justify-content: center; } #gstInfoDiv, #solarInfoDiv { margin: 40px; letter-spacing: 1.7px; } #nasaInfoContainer { position: absolute; display: flex; justify-content: center; width: 100%; height: 100%; top: 0; left: 0; pointer-events: none; overflow-x: hidden; overflow-y: scroll; } #nasaInfo { max-width: 2200px; width: 92%; max-height: 65%; height: 0; padding: 20px; font-size: 20px; margin-top: -100px; line-height: 30px; overflow: scroll; pointer-events: auto; transition: all 1s ease; outline: none; z-index: 9; background: var(--color1); border: 0; border-bottom: 2px solid !important; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 15%, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%) !important; border-image-slice: 1 !important; } #nasaInfo.showInfo { transition: all 0.6s ease; height: 100%; margin-top: 0px; padding-top: 140px; } //dinosaur #nasaDinoMainContainer { position: absolute; max-width: 100%; width: 100%; height: 100%; transform: translateY(-50%); z-index: 999; bottom: 0; right: 0; pointer-events: none; } #nasaDinoContainer { display: flex; justify-content: flex-end; max-width: 2200px; width: 94%; height: 100%; } #nasaDino { position: absolute; height: 300%; width: 145px; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/bef564cf-4838-4a1e-b3e8-7afa3879f30e/BaseDino1.png'); background-repeat: no-repeat; background-position: center; background-size: contain; bottom: 0; margin-right: 15%; transition: all 0.6s linear; pointer-events: auto; cursor: pointer; } #nasaDino:hover, #nasaDino.showInfo:hover { transform: translateY(-3%) scale(1.05); transition: all 0.3s ease; } @media only screen and (max-width:790px) { #nasaDino { margin-right: 0; } } #nasaDino.showInfo { margin-right: 10%; transform: translateY(4.5%) scale(0.9); } #nasaDino.happy { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/36bb29bb-c33e-462f-a974-68b398780469/HappyDino1+copy.png'); } #nasaDino.sad { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/a494977d-d65e-4c05-ac3b-f94eb839c68c/SadDino1+copy.png'); } #nasaDino.angry { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/a1a9ff88-54a1-45ab-b1d8-09fac99853a5/AngryDino1+copy.png'); } #nasaDino.shy { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/f0b29979-8d35-4489-9d44-9a07acf386a7/ShyDino1+copy.png'); } #nasaDino.stupid { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/3148a739-800a-42d5-80a7-db6d03b9f37a/RetardDino1+copy.png'); }
code block script copy buttons
Added copy to clipboard buttons for easier grabbing of the code provided in my blog posts, or anywhere else on the website. As long as there is text inside a code block, a copy button is added to it. It checks if the first line of code is not a descriptor, as I use them - such as “CSS:”, “HTML:”, or “JAVASCRIPT:”. After excluding the descriptor if present, it copies the contents of the code block to clipboard. The buttons are not displayed on mobile. The check to see if the code block actually contains text excludes normal code blocks which do not display the source code from having buttons, as well as code blocks which contain only a descriptor.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { // Mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (isTouch === false) { //initialize popover let hideTimeout; var popover = document.getElementById('custom-popover'); var popoverMessage = document.getElementById('popover-message'); function showPopover(message) { popoverMessage.textContent = message; popover.classList.add('show'); popover.style.pointerEvents = 'auto'; clearTimeout(hideTimeout); hideTimeout = setTimeout(function() { popover.classList.remove('show'); popover.style.pointerEvents = 'none'; }, 3000); } //add the copy buttons const codeBlocks = document.querySelectorAll('.code-block'); codeBlocks.forEach(function (block) { let codeText; // Get the inner text and check if it's not empty const codeLines = block.innerText.split('\n'); if (codeLines.length > 0 && (codeLines[0].trim() === 'HTML:' || codeLines[0].trim() === 'JAVASCRIPT:' || codeLines[0].trim() === 'CSS:')) { codeText = codeLines.slice(1).join('\n'); } else { codeText = block.innerText; } // Check if codeText is not empty before adding the copy button if (codeText.trim() !== '') { const copyButton = document.createElement('div'); copyButton.classList.add('codeBlockCopyBtn'); copyButton.addEventListener('click', function () { // Check if codeText is not empty before copying if (codeText.trim() !== '') { navigator.clipboard.writeText(codeText).then(function() { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('Code copied'); }).catch(function(err) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Failed to copy code'); }); } else { popoverMessage.style.color = "#ea4b1a"; showPopover('No code to copy'); } }); block.appendChild(copyButton); } }); } }); </script>
CSS: .codeBlockCopyBtn { position: absolute; top: 45px; left: 0; margin-left: -50px; width: 45px; height: 45px; cursor: pointer; pointer-events: auto; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/60945976-1d8a-4994-b513-32f2ef4c2269/papericon2.png'); background-repeat: no-repeat; background-position: center; background-size: contain; transition: all 0.6s ease; } .codeBlockCopyBtn:hover { transition: all 0.3s ease; scale: 1.15; } @media only screen and (max-width:790px) { .codeBlockCopyBtn { display: none; } }
Real time geographical dynamic weather effects
The following code was written to display weather effects, depending on the real world weather forecast of the visitor’s location. Weather can have a profound effect on our mood, even if we don’t like to admit it. I found it fitting to take that potential mood as a variable in customizing the user experience on my website and so the idea was born.
An API call to open-meteo.com is made to get current weather upon which the weather data is stored in local storage to minimize the number of API calls. The weather data is only updated from the API if it is older than 30 minutes, a time which roughly corresponds to a prolonged use of the website, updates pushed by the weather service, and a period in which the weather actually changes enough to potentially display a different banner. The API used is free for up to 10k calls per day and requires no key. The API call requires latitude and longitude of the visitor, which is begotten through another API which gets the data through the visitor’s IP address, because I disliked having to prompt the visitor for their location. As such it may not be accurate for visitors who run a VPN, or are otherwise masking their IP address.
After retrieving and storing the weather data it is parsed to deduce the weather code and some other parameters, such as whether it is day or night, wind speed and direction, precipitation, and temperature. Currently I use the weather codes to decide which effects to apply. A weather code is a meteorological shorthand for the type of weather a region is experiencing, most common being rain, drizzle, fog, sunshine, storm, snowfall, and some other. I consolidate most of these codes into a few categories to keep the effects manageable, however, should one want to, expansion and granular weather representation is possible.
The weather effects themselves are CSS animations and different CSS properties applied to the weather container which serves as the main stage for displaying different weather states to the visitor. Inside the specified weather functions I further break down the weather and manipulate the animations depending on wind direction, wind speed, and the sunrise/sunset times. If the weather is clear and it is night time, for example, approximate moon position is calculated and displayed.
Inside the main weather banner, just below the header, is an overlay which becomes visible on hovering over it, which can toggle weather effects for those of you who prefer simplicity in design. This preference is stored in session storage and persists until the browser tab is closed. There is also a bit of code to adjust the weather banner depending on which page is viewed, zoom applied to the body of the page, and for mobile devices. There is also a bit of code to keep the button bar tight against the header, regardless of zoom or window size. Since all the animations are done in CSS, it is a light weight solution for my rig, and hopefully yours too.
Also you may notice that I store the weather type in session storage. This is done to dynamically adapt other sections of the website depending on the type of weather. Currently my main shop category displays different descriptions depending on which weather is outside of your window. I have a blog post about adding shop category descriptions if you are interested.
Lastly, I implemented fail safes for when the weather data is not available. There is a default weather mock up which is initiated if the API call returns bad data, or if the IP of the user can not be obtained. This ensures that even if the first API call fails, the weather is still displayed, although not in correspondence with the real time data.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const weatherUpdateInterval = 0.5 * 60 * 60 * 1000; //how often we can do api call const sectionBG = document.querySelector('.section-background'); const weatherContainer = document.createElement('div'); weatherContainer.id = "weatherContainer"; weatherContainer.classList.add('weatherContainer'); const weatherMainContainer = document.createElement('div'); weatherMainContainer.id = "weatherMainContainer"; weatherMainContainer.classList.add('weatherMainContainer'); weatherMainContainer.appendChild(weatherContainer); sectionBG.appendChild(weatherMainContainer); //below the header // Mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //to clear existing weather function removeWeatherEffects(){ weatherContainer.classList.remove('rainy'); weatherContainer.classList.remove('windrainyright'); weatherContainer.classList.remove('windrainyleft'); weatherContainer.classList.remove('snowy'); weatherContainer.classList.remove('drizzly'); weatherContainer.classList.remove('cloudy'); weatherContainer.classList.remove('sunny'); weatherContainer.classList.remove('moony'); weatherContainer.classList.remove('foggy'); weatherContainer.classList.remove('stormy'); } //adjust weather size per page const shopString = '/shop'; const secretShopString = '/sanctumshop'; const productPageString = 'shop/p/'; const artString = '/art'; const loginString = '/login'; const signupString = '/signup'; const dossierString = '/accountdossier'; const cartPageString = '/cart'; const gallerySection = document.querySelector('.gallery-masonry'); const userGalleryString = '/usergallery'; const devlogString = '/developmentlog'; const devlogListString = '/devlog'; const getURL = window.location.href; function adjustWeatherContainer() { const weatherType = sessionStorage.getItem('weatherType'); const zoomFactor = parseFloat(document.body.style.zoom) || 1; if (weatherType !== 'sunny' && weatherType !== 'moony'){ //Exclude bad scaling weathers if (getURL.includes(artString)) { if (isTouch === false) { weatherMainContainer.style.height = `${260 * zoomFactor}px`; } else { weatherMainContainer.style.height = `${200 * zoomFactor}px`; } } else if (getURL.includes(productPageString)) { if (isTouch === false) { weatherMainContainer.style.height = `${200 * zoomFactor}px`; } else { weatherMainContainer.style.display = 'none'; } } else if (getURL.includes(shopString) || getURL.includes(secretShopString)) { if (isTouch === false) { weatherMainContainer.style.height = `${290 * zoomFactor}px`; } else { weatherMainContainer.style.height = `${550 * zoomFactor}px`; } } else if (getURL.includes(loginString) || getURL.includes(signupString) || getURL.includes(dossierString) || getURL.includes(cartPageString) || getURL.includes(userGalleryString)) { weatherMainContainer.style.display = 'none'; } else if (!getURL.includes(artString) && gallerySection) { weatherMainContainer.style.display = 'none'; } else if (getURL.includes(devlogString)){ if (isTouch === false) { weatherMainContainer.style.height = `${247 * zoomFactor}px`; } else { weatherMainContainer.style.height = `${255 * zoomFactor}px`; } } else if (getURL.includes(devlogListString)){ if (isTouch === false) { weatherMainContainer.style.height = `${280 * zoomFactor}px`; } else { weatherMainContainer.style.height = `${200 * zoomFactor}px`; } } } else { weatherMainContainer.style.height = `${270 * zoomFactor}px`; } } //weather implementations function convertToMinutes(timeString) { const date = new Date(timeString); const hours = date.getHours(); const minutes = date.getMinutes(); return hours * 60 + minutes; } function calculateMoonPosition() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const sunriseTime = weatherData.sunrise; const sunsetTime = weatherData.sunset; const now = new Date(); const hours = now.getHours(); const minutes = now.getMinutes(); const totalMinutes = hours * 60 + minutes; const nightStart = convertToMinutes(sunsetTime); const nightEnd = convertToMinutes(sunriseTime); if (totalMinutes >= nightStart || totalMinutes < nightEnd) { let progress; if (totalMinutes >= nightStart) { progress = (totalMinutes - nightStart) / ((nightEnd + 24 * 60) - nightStart); } else { progress = ((totalMinutes + 24 * 60) - nightStart) / ((nightEnd + 24 * 60) - nightStart); } const animationDuration = 60; //duration of the css animation const currentAnimationTime = progress * animationDuration; weatherContainer.style.animationPlayState = 'paused'; weatherContainer.style.animationDelay = `-${currentAnimationTime}s`; } else { weatherContainer.style.animationPlayState = 'running'; } } function sunny() { //or moony const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; const isDay = weather.is_day === 1; removeWeatherEffects(); if (isDay === true) { weatherContainer.classList.add('sunny'); sessionStorage.setItem('weatherType', 'sunny'); } else { weatherContainer.classList.add('moony'); sessionStorage.setItem('weatherType', 'moony'); calculateMoonPosition(); setInterval(calculateMoonPosition, 60 * 1000); } } function cloudy() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; removeWeatherEffects(); weatherContainer.classList.add('cloudy'); const winddirection = weather.winddirection; const windspeed = weather.windspeed; const newAnimationDuration = 120 / (windspeed / 8); //clouds speed weatherContainer.style.animationDuration = `${newAnimationDuration}s`; if (winddirection <= 180) { weatherContainer.style.animationDirection = 'normal'; } else { weatherContainer.style.animationDirection = 'reverse'; } sessionStorage.setItem('weatherType', 'cloudy'); } function fog() { removeWeatherEffects(); weatherContainer.classList.add('foggy'); sessionStorage.setItem('weatherType', 'foggy'); } function drizzle() { removeWeatherEffects(); weatherContainer.classList.add('drizzly'); sessionStorage.setItem('weatherType', 'drizzly'); } function rain() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; const winddirection = weather.winddirection; const windspeed = weather.windspeed; removeWeatherEffects(); if (windspeed <= 15) { weatherContainer.classList.add('rainy'); } else if (winddirection <= 180) { weatherContainer.classList.add('windrainyleft'); } else { weatherContainer.classList.add('windrainyright'); } sessionStorage.setItem('weatherType', 'rainy'); } function snow() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; removeWeatherEffects(); weatherContainer.classList.add('snowy'); const weatherCode = weather.weathercode; const windspeed = weather.windspeed; const newAnimationDuration = 33 / (windspeed / 8); //snowfall speed weatherContainer.style.animationDuration = `${newAnimationDuration}s`; sessionStorage.setItem('weatherType', 'snowy'); } function thunderstorm() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; removeWeatherEffects(); weatherContainer.classList.add('stormy'); const winddirection = weather.winddirection; const windspeed = weather.windspeed; const newAnimationDuration = 58 / (windspeed / 13); //stormcloud speed weatherContainer.style.animationDuration = `${newAnimationDuration}s`; sessionStorage.setItem('weatherType', 'stormy'); } //fallback if api not available function defaultWeatherSystem() { const weather = { weather: { time: "2024-09-27T22:00", interval: 900, temperature: 14.2, windspeed: 18, winddirection: 329, is_day: 0, weathercode: 0 }, sunrise: "2024-09-27T06:00", sunset: "2024-09-27T21:00", timestamp: Date.now() }; const now = new Date(); const hours = now.getHours(); if (hours <= 5) { rain(weather); } else if (hours <= 9) { drizzle(); } else if (hours <= 15) { sunny(weather); } else if (hours <= 18) { fog(weather); } else if (hours <= 21) { thunderstorm(weather); } else if (hours <= 22) { cloudy(weather); } else { snow(); } } //choose weather type function applyWeatherEffects() { const storedWeatherData = localStorage.getItem('weatherData'); const weatherData = JSON.parse(storedWeatherData); const weather = weatherData.weather; const weatherCode = weather.weathercode; if (weatherCode === 0 || weatherCode === 1) { sunny(); } else if (weatherCode === 2 || weatherCode === 3) { cloudy(); } else if (weatherCode === 45 || weatherCode === 48) { fog(); } else if (weatherCode === 51 || weatherCode === 53 || weatherCode === 55 || weatherCode === 56 || weatherCode === 57) { drizzle(); } else if (weatherCode === 61 || weatherCode === 63 || weatherCode === 65 || weatherCode === 66 || weatherCode === 67 || weatherCode === 80 || weatherCode === 81 || weatherCode === 82) { rain(); } else if (weatherCode === 71 || weatherCode === 73 || weatherCode === 75 || weatherCode === 77 || weatherCode === 85 || weatherCode === 86) { snow(); } else if (weatherCode === 95 || weatherCode === 96 || weatherCode === 99) { thunderstorm(); } else { sunny(); } } //get weather data by location function getWeatherData(latitude, longitude) { const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&daily=sunrise,sunset`; //this link is not correct, look at the end of the post to copy and paste the correct API call constant fetch(apiUrl) .then(response => { if (!response.ok) { defaultWeatherSystem(); } return response.json(); }) .then(data => { const weather = data.current_weather; const weatherCode = weather.weathercode; const sunriseTime = data.daily.sunrise[0]; const sunsetTime = data.daily.sunset[0]; const weatherData = { weather: weather, sunrise: sunriseTime, sunset: sunsetTime, timestamp: Date.now() }; localStorage.setItem('weatherData', JSON.stringify(weatherData)); applyWeatherEffects(); }) .catch(error => { defaultWeatherSystem(); }); } //get approximate location of the visitor function getLocationByIP() { const defaultLatitude = 51.9966; const defaultLongitude = 4.4682; fetch('https://ipapi.co/json/') .then(response => response.json()) .then(data => { const latitude = data.latitude || defaultLatitude; const longitude = data.longitude || defaultLongitude; getWeatherData(latitude, longitude); }) .catch(error => { getWeatherData(defaultLatitude, defaultLongitude); }); } //check if we already have up to date weather data function checkWeatherData() { const storedWeatherData = localStorage.getItem('weatherData'); if (!storedWeatherData) { return getLocationByIP(); } const { weather, timestamp } = JSON.parse(storedWeatherData); const currentTime = Date.now(); if (currentTime - timestamp < weatherUpdateInterval) { return applyWeatherEffects(weather); } return getLocationByIP(); } //bar button to toggle weather effects let weatherOn = sessionStorage.getItem('weatherOn'); if (weatherOn === null) { sessionStorage.setItem('weatherOn', true); weatherOn = true; } else { weatherOn = weatherOn === 'true'; } const weatherButton = document.createElement('div'); weatherButton.id = "weatherButton"; weatherButton.classList.add('weatherButton'); const weatherButtonContainer = document.createElement('div'); weatherButtonContainer.id = "weatherButtonContainer"; weatherButtonContainer.classList.add('weatherButtonContainer'); weatherButtonContainer.appendChild(weatherButton); weatherMainContainer.appendChild(weatherButtonContainer); weatherButton.innerHTML = 'Toggle Weather'; const header = document.querySelector('#header'); const headerHeight = header.offsetHeight; weatherButton.style.top = `${headerHeight - 5}px`; setTimeout(() => { //delayed execution on all calcs that involve zoom const zoomFactor = parseFloat(document.body.style.zoom) || 1; weatherButton.style.height = `${45 * zoomFactor * zoomFactor}px`; }, 0); weatherButton.addEventListener('click', function() { if (weatherOn === false) { sessionStorage.setItem('weatherOn', true); weatherOn = true; checkWeatherData(); adjustWeatherContainer(); } else { sessionStorage.setItem('weatherOn', false); weatherOn = false; removeWeatherEffects(); } }); //Initialize if (weatherOn === true) { removeWeatherEffects(); checkWeatherData(); setTimeout(() => { adjustWeatherContainer(); }, 0); } //exit }); </script>
CSS: @keyframes sunshine { 0% {background-position: 180% -50%;} 10% {background-color: transparent;} 50% {background-position: 50% 50%; background-color: #ffffff; opacity: 0.75;} 90% {background-color: #cc0000;} 100% {background-position: -80% -50%;} } @keyframes snowfall { 0% {background-position: 0 0;} 25% {background-position: -1% 125px;} 50% {background-position: 0 250px;} 75% {background-position: 1% 375px;} 100% {background-position: 0 500px;} } @keyframes rain { 0% { background-position: 0 0;} 100% { background-position: 0% 500px;} } @keyframes diagonalrainleft { 0% {background-position: 0 0;} 100% {background-position: -500px 500px;} } @keyframes diagonalrainright { 0% {background-position: 0 0;} 100% {background-position: 500px 500px;} } @keyframes clouds { 0% {background-position: 0px 25px;} 50% {background-position: 1200px 15px;} 100% {background-position: 2400px 25px;} } @keyframes storm { 0% {background-position: 0px 25px;} 10.5% {background-color: transparent; opacity: 0.65;} 11% {background-color: #ffffff; opacity: 0.98;} 13% {background-color: transparent; opacity: 0.73;} 13.2% {background-color: #000000;} 13.5% {background-color: #ffec94; opacity: 0.98;} 16% {background-color: transparent; opacity: 0.73;} 50% {background-position: 1200px 15px;} 55.6% {background-color: transparent; opacity: 0.65;} 55.9% {background-color: #ffec94; opacity: 0.98;} 59% {background-color: transparent; opacity: 0.65;} 62% {background-position: 1500px 20px;} 66.7% {background-color: transparent; opacity: 0.73;} 67% {background-color: #ffffff; opacity: 0.98;} 71% {background-color: transparent; opacity: 0.73;} 91% {background-color: transparent; opacity: 0.73;} 91.2% {background-color: #000000;} 91.4% {background-color: #ffec94; opacity: 0.98;} 94% {background-color: transparent; opacity: 0.65;} 100% {background-position: 2400px 25px;} } @keyframes moonwalk { 0% {background-position: 100% 100px; background-color: transparent; opacity: 0.5;} 35% {background-position: 65% 35px; opacity: 0.8;} 50% {background-position: 50% 25px; background-color: #000099; opacity: 0.9;} 65% {background-position: 35% 35px; opacity: 0.8;} 100% {background-position: 0% 100px; background-color: transparent; opacity: 0.5;} } //WEATHER EFFECTS// #weatherButtonContainer { position: absolute; max-width: 2200px; width: 100%; height: 100%; } #weatherButton { background: linear-gradient(180deg, rgba(0,0,0,1) 10%, rgba(0,0,0,0) 100%); width: 60%; height: 45px; position: absolute; display: flex; top: 100px; left: 50%; transform: translateX(-50%); cursor: pointer; pointer-events: auto; z-index: 10; transition: all 1s ease; opacity: 0; color: var(--color1); text-align: center; padding: 8px; justify-content: center; align-items: center; letter-spacing: 2px; font-size: 16px; mask-image: linear-gradient(90deg, rgba(235,0,0,0) 4%, rgba(0,0,0,1) 12%, rgba(0,0,0,1) 88%, rgba(0,0,0,0) 96%); -webkit-mask-image: linear-gradient(90deg, rgba(235,0,0,0) 4%, rgba(0,0,0,1) 12%, rgba(0,0,0,1) 88%, rgba(0,0,0,0) 96%); } #weatherButton:hover { opacity: 0.45; } @media only screen and (max-width:790px) { #weatherButton { width: 100%; } } #weatherMainContainer { position: absolute; pointer-events: none; display: flex; justify-content: center; top: 0; left: 0; width: 100%; height: 270px; z-index: 10; overflow: hidden; mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); } #weatherContainer { max-width: 2200px; width: 100%; height: 100%; background-repeat: repeat; background-size: cover; mask-image: linear-gradient(90deg, rgba(235,0,0,0) 4%, rgba(0,0,0,1) 12%, rgba(0,0,0,1) 88%, rgba(0,0,0,0) 96%); -webkit-mask-image: linear-gradient(90deg, rgba(235,0,0,0) 4%, rgba(0,0,0,1) 12%, rgba(0,0,0,1) 88%, rgba(0,0,0,0) 96%); } #weatherContainer.snowy { animation: snowfall 33s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/8096f400-fe6f-41bc-9a56-002bd32b7300/snowPattern1.png'); background-size: 500px 500px; opacity: 1; } #weatherContainer.rainy { animation: rain 1.73s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/6549860a-012c-4d07-8739-e18af61643bb/rainPattern1.png'); background-size: 500px 500px; opacity: 0.3; } #weatherContainer.windrainyleft { animation: diagonalrainleft 1.6s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/6451eb03-ba92-4327-ae54-73eb5ff89cbe/diagRainLeft1.png'); background-size: 500px 500px; opacity: 0.5; } #weatherContainer.windrainyright { animation: diagonalrainright 1.6s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/bbc8882a-bed7-45d4-a81f-7739258844a7/diagRainRight1.png'); background-size: 500px 500px; opacity: 0.5; } #weatherContainer.drizzly { animation: rain 1.93s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/7c91a254-d1f9-425b-a4c8-c6094da5b02f/DrizzlePattern1+copy.png'); background-size: 500px 500px; opacity: 0.3; } #weatherContainer.cloudy { animation: clouds 120s linear infinite reverse; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/16fc7e70-6d05-4a55-b869-a72a0ebdd463/CloudsHorizontalTile1.png'); background-size: 2400px 300px; opacity: 0.73; filter: blur(2px); } #weatherContainer.stormy { animation: storm 58s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/16fc7e70-6d05-4a55-b869-a72a0ebdd463/CloudsHorizontalTile1.png'); background-size: 2400px 320px; filter: blur(1px); } #weatherContainer.foggy { animation: clouds 200s linear infinite; background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/16fc7e70-6d05-4a55-b869-a72a0ebdd463/CloudsHorizontalTile1.png'); background-size: 3500px 300px; opacity: 0.5; filter: blur(15px); background-color: #ffffff; } #weatherContainer.sunny { background: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(0,0,0,0) 50%); animation: sunshine 50s infinite linear; opacity: 0.3; background-repeat: no-repeat; background-size: 50%; } #weatherContainer.moony { animation: moonwalk 60s infinite linear; background: radial-gradient(circle, rgba(255,232,153,1) 17%, rgba(255,224,99,0.7) 17.1%, rgba(0,0,0,0) 25%); background-repeat: no-repeat; background-size: 8%; }
This should be a ready to copy and paste implementation provided you make the weather effect textures, however, I strongly encourage you to make adjustments to fit your website. Note that the line “const apiUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&daily=sunrise,sunset`;” is the correct api call constant. My automatic highlighting distorts this link in the code above.
shop prices in different currencies with free API
The following code was added to allow browsing the shop in different currencies. The cost was meant to be kept minimal, while still making occasional API calls to update the currency rates, every 48 hours. I am using an API for this which is free for up to 5000 requests per month. To minimize the cost I make a single request upon selecting a different currency and store all of the rates in local storage, meaning all subsequent currency selections don’t incur any cost, unless the rates are outdated (older than 48 hours), or local storage is cleared. As of the last update, I store the currency rates in my firebase database, which further minimizes API calls. So even if the local storage is cleared, the rates are first queried from firebase, and only if those are outdated is an API call made.
A lot of the code is for creating and positioning the custom made menu for currency selection. The actual conversion is not complicated. Because I store rates with a single API request, the conversion takes place on the front-end. The API used is from freecurrencyapi.com.
The code presented is merely a proof of concept, as a lot of variables and statements will have to be adjusted if you are not using euros as your base currency in squarespace. As for now, it functions to maintain the selected currency for your browsing session, and takes into account price tag changes which occur when you select a different product variant with a different price. During the API call my base currency is set to USD, not euro. There is a conversion step in the script to allow EUR to become the base currency with the rate of 1.
HTML:
JAVASCRIPT: <script type="module"> //firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, doc, getDoc, setDoc, serverTimestamp, Timestamp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js'; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); //main async function loadCurrency() { const subString = 'shop'; const subStringTwo = 'shop/p/'; const getURL = window.location.href; if (getURL.includes(subString)) { let categoryView = true; const currencyUpdateInterval = 48 * 60 * 60 * 1000; const currencySymbols = { AUD: 'A$', BGN: 'лв', BRL: 'R$', CAD: 'C$', CHF: 'CHF', CNY: '¥', CZK: 'Kč', DKK: 'kr', EUR: '€', GBP: '£', HKD: 'HK$', HRK: 'kn', HUF: 'Ft', IDR: 'Rp', ILS: '₪', INR: '₹', ISK: 'kr', JPY: '¥', KRW: '₩', MXN: '$', MYR: 'RM', NOK: 'kr', NZD: 'NZ$', PHP: '₱', PLN: 'zł', RON: 'lei', RUB: '₽', SEK: 'kr', SGD: 'S$', THB: '฿', TRY: '₺', USD: '$', ZAR: 'R' }; // Mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //initialize popover let hideTimeout; var popover = document.getElementById('custom-popover'); var popoverMessage = document.getElementById('popover-message'); function showPopover(message) { popoverMessage.textContent = message; popover.classList.add('show'); popover.style.pointerEvents = 'auto'; clearTimeout(hideTimeout); hideTimeout = setTimeout(function() { popover.classList.remove('show'); popover.style.pointerEvents = 'none'; }, 3000); } // Set currency function setCurrency(currency) { sessionStorage.setItem('selectedCurrency', currency); function updatePrices(){ const currencyRates = localStorage.getItem('currencyRates'); const currencyRatesTime = localStorage.getItem('currencyRatesTime'); const ratesObject = JSON.parse(currencyRates); const eurRate = ratesObject.data['EUR']; for (const currency in ratesObject.data) { ratesObject.data[currency] = ratesObject.data[currency] / eurRate; // Convert to EUR base } const rate = ratesObject.data[currency]; const priceTags = document.querySelectorAll('.product-price'); priceTags.forEach(priceTag => { if (!priceTag.dataset.originalPrice) { let originalPrice = parseFloat(priceTag.textContent.replace(/[^0-9.-]+/g, '')); priceTag.dataset.originalPrice = originalPrice; } let originalPrice = parseFloat(priceTag.dataset.originalPrice); let rate = ratesObject.data[currency]; let updatedPrice = originalPrice * rate; priceTag.innerHTML = currencySymbols[currency] + updatedPrice.toFixed(2); //update price tag on variant select function handleVariantChange() { setTimeout(() => { let priceText = priceTag.textContent.trim(); let currencySymbol = priceText.match(/[^0-9.,\s]+/)[0]; if (currencySymbol === '€') { currency = sessionStorage.getItem('selectedCurrency'); originalPrice = parseFloat(priceTag.textContent.replace(/[^0-9.-]+/g, '')); priceTag.dataset.originalPrice = originalPrice; rate = ratesObject.data[currency]; updatedPrice = originalPrice * rate; priceTag.innerHTML = currencySymbols[currency] + updatedPrice.toFixed(2); } }, 0); } //monitor price tag changes due to variant selection let variantSelectors = priceTag.closest(categoryView ? '.grid-item' : '.ProductItem').querySelectorAll('select'); variantSelectors.forEach(selector => { if (!selector.dataset.listenerAdded) { selector.addEventListener('change', handleVariantChange); selector.dataset.listenerAdded = true; //make sure we dont add the listener twice } }); }); } function storeRates(rates){ localStorage.setItem('currencyRates', rates); const currentTime = new Date().toISOString(); localStorage.setItem('currencyRatesTime', currentTime); updatePrices(); } //try get rates from firestore async function getNewRates(){ const ratesDocRef = doc(db, 'currency', 'conversions'); try { const ratesSnapshot = await getDoc(ratesDocRef); const currentTime = new Date(); if (ratesSnapshot.exists()) { const data = ratesSnapshot.data(); const rates = data.rates; if (rates) { const fetchedTime = data.ratesFetchedAt.toDate(); const differenceInMillis = currentTime - fetchedTime; if (differenceInMillis < currencyUpdateInterval) { storeRates(rates); return; } } //otherwise get rates from api var oReq = new XMLHttpRequest(); oReq.addEventListener("load", function () { const newRates = this.responseText; storeRates(this.responseText); setDoc(ratesDocRef, { rates: newRates, ratesFetchedAt: serverTimestamp() }); }); oReq.open("GET", "https://api.freecurrencyapi.com/v1/latest?apikey=YOURFREEAPIKEYGOESHERE"); oReq.send(); } } catch (error) { console.error("Error fetching rates:", error); } } //but first try get rates from local storage const currencyRates = localStorage.getItem('currencyRates'); const currencyRatesTime = localStorage.getItem('currencyRatesTime'); if (!currencyRates || !currencyRatesTime) { getNewRates(); } else { const storedTime = new Date(currencyRatesTime); const differenceInMillis = new Date() - storedTime; if (differenceInMillis > currencyUpdateInterval) { getNewRates(); } else { updatePrices(); } } } // Make custom options button with icon function makeSelect() { const newSelectButton = document.createElement('div'); newSelectButton.classList.add('newSelectButton'); let selectText = 'EUR'; const selectedCurrency = sessionStorage.getItem('selectedCurrency'); if (selectedCurrency){ selectText = selectedCurrency; } const iconDiv = document.createElement('div'); iconDiv.id = "currencyIcon"; iconDiv.classList.add('currencyIcon'); newSelectButton.appendChild(iconDiv); const buttonText = document.createElement('div'); newSelectButton.appendChild(buttonText); buttonText.innerHTML = selectText; buttonText.classList.add('newSelectButtonText'); // Show the custom dropdown newSelectButton.addEventListener('click', function (event) { event.preventDefault(); const existingContainer = document.getElementById('customDropdownContainer'); if (existingContainer) { existingContainer.remove(); } // Create new dropdown container const container = document.createElement('div'); container.id = 'customDropdownContainer'; container.classList.add('customDropdownContainer'); const optionContainer = document.createElement('div'); optionContainer.id = 'customDropdownOptionContainer'; optionContainer.classList.add('customDropdownOptionContainer'); container.appendChild(optionContainer); // Populate menu const optionEmptyStart = document.createElement('div'); optionEmptyStart.classList.add('customDropdownEmptyOption'); optionEmptyStart.id = 'optionEmptyStart'; optionContainer.appendChild(optionEmptyStart); const options = [ "AUD", "BGN", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR", "GBP","HKD", "HRK", "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN","MYR", "NOK", "NZD", "PHP", "PLN", "RON", "RUB", "SEK", "SGD", "THB", "TRY", "USD", "ZAR" ]; for (let i = 0; i < options.length; i++) { const option = document.createElement('div'); option.classList.add('customDropdownOption'); if (i !== 0) { option.innerHTML = options[i]; // Select the option and close the dropdown option.addEventListener('click', function () { setCurrency(options[i]); buttonText.innerHTML = options[i]; container.remove(); if (buttonText.innerHTML !== 'EUR') { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('Prices on check-out will still be displayed in € (Euro).'); } }); optionContainer.appendChild(option); } } const optionEmptyEnd = document.createElement('div'); optionEmptyEnd.classList.add('customDropdownEmptyOption'); optionEmptyEnd.id = 'optionEmptyEnd'; optionContainer.appendChild(optionEmptyEnd); // Append dropdown to body and position it document.body.appendChild(container); const zoomFactor = parseFloat(document.body.style.zoom) || 1; const rectSelect = newSelectButton.getBoundingClientRect(); container.style.position = 'absolute'; container.style.top = `${(rectSelect.top + window.scrollY + 2) / zoomFactor}px`; container.style.left = `${(rectSelect.left + window.scrollX - 2) / zoomFactor}px`; container.style.width = `${newSelectButton.offsetWidth - 5 * zoomFactor}px`; // Close dropdown when clicking outside document.addEventListener('click', function closeDropdown(event) { if (!container.contains(event.target) && event.target !== newSelectButton) { container.remove(); document.removeEventListener('click', closeDropdown); } }); }); // Append in category view function appendCurrencyMainContainer(searchInput) { const nestedCategories = document.querySelector('.nested-category-tree-wrapper'); const mainContainer = document.createElement('div'); mainContainer.id = 'currencyMainContainer'; mainContainer.classList.add('currencyMainContainer'); const currencyButtonContainer = document.createElement('div'); currencyButtonContainer.id = 'currencyButtonContainer'; currencyButtonContainer.classList.add('currencyButtonContainer'); currencyButtonContainer.appendChild(newSelectButton); mainContainer.appendChild(currencyButtonContainer); nestedCategories.insertBefore(mainContainer, searchInput); } // Append in product page above breadcrumbs function appendCurrencyMainContainerProductView(breadcrumbs) { const mainContainer = document.createElement('div'); mainContainer.id = 'currencyMainContainer'; mainContainer.classList.add('currencyMainContainer'); const currencyButtonContainer = document.createElement('div'); currencyButtonContainer.id = 'currencyButtonContainer'; currencyButtonContainer.classList.add('currencyButtonContainer'); currencyButtonContainer.appendChild(newSelectButton); mainContainer.appendChild(currencyButtonContainer); breadcrumbs.parentNode.insertBefore(mainContainer, breadcrumbs); } // Wait for elements to load in and insert const observer = new MutationObserver(mutationsList => { mutationsList.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.id === 'shopSearchInput') { appendCurrencyMainContainer(node); observer.disconnect(); } else if (node.classList && node.classList.contains('custom-breadcrumb-container')) { if (isTouch === false) { //not available on mobile on product page appendCurrencyMainContainerProductView(node); } observer.disconnect(); } }); }); }); const targetNode = document.body; const config = { childList: true, subtree: true }; observer.observe(targetNode, config); } makeSelect(); //update prices if currency is set const selectedCurrency = sessionStorage.getItem('selectedCurrency'); if (selectedCurrency){ const nestedCategories = document.querySelector('.nested-category-tree-wrapper'); if (nestedCategories) { categoryView = true; } else { categoryView = false; } setCurrency(selectedCurrency); } } } loadCurrency(); </script>
CSS: #currencyMainContainer { display: flex; justify-content: flex-start; align-items: center; width: 100%; height: auto; z-index: 9999; font-size: 20px; text-transform: uppercase; margin: 0; margin-bottom: 10px; margin-top: -70px; margin-left: 25px; } #currencyButtonContainer { display: flex; justify-content: flex-start; width: 100px; align-items: center; } .currencyIcon { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/d6427bf3-e1ef-4ce5-9333-7f75785b5728/coinstackIcon1.png'); border: 0px !important; width: 25px !important; height: 25px !important; margin-left: -40px; margin-top: 2px; position: absolute; pointer-events: none; background-repeat: no-repeat; background-position: center; background-size: contain; } //mobile currency menu @media only screen and (max-width:790px) { #currencyMainContainer { margin: 0; justify-content: center; margin-bottom: 20px; margin-top: -20px; margin-left: 55px; } }
Custom drop-down menus in shop
Stock drop-down menus are limited when it comes to styling them. I wanted to have a specific appearance for my menus, so I made my own to replace the stock ones. In order to keep the functionality of the old ones I didn’t get rid of the stock menus, instead I’ve hidden them and routed the events from the new menu to the old one. This way squarespace keeps all the functionality which is tied to product variant selection, and I get to style my menus. I have also merged my previous blog post about adding spinning gear icons in front of the menus, into this code. If you followed my blog post about the gear icons, you can delete the pertaining code. During placement of the menu, I divide the position by the zoom factor, which may be applied to the body in CSS, to not be affected by it. It is normally 1, but if you followed my previous blog post about adjusting zoom for lower resolutions, it is especially handy to have. It is also good future proofing practice to account for zoom levels when positioning elements so that later changes will not produce unwieldy results.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { //mobile check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); const subString = '/shop'; const subStringTwo = 'shop/p/'; const getURL = window.location.href; //make custom options button with gear spinner icon function substituteSelect(select) { const newSelectButton = document.createElement('div'); newSelectButton.classList.add('newSelectButton'); const selectText = select.getAttribute('data-variant-option-name') || 'Choose an option'; select.style.display = 'none'; select.parentNode.appendChild(newSelectButton); const gearDiv = document.createElement('div'); gearDiv.id = "spinner"; gearDiv.classList.add('option-select-gear', 'spinner'); newSelectButton.appendChild(gearDiv); const buttonText = document.createElement('div'); newSelectButton.appendChild(buttonText); buttonText.innerHTML = selectText; buttonText.classList.add('newSelectButtonText'); //adjust positioning if (getURL.includes(subStringTwo)) { //product page newSelectButton.style.marginTop = '20px' gearDiv.style.top = isTouch ? '6px' : '1px'; } else { //shop grid category view gearDiv.style.top = isTouch ? '15px' : '10px';; } // When the custom button is clicked, show the custom dropdown newSelectButton.addEventListener('click', function (event) { event.preventDefault(); // Remove existing dropdown to avoid duplicates const existingContainer = document.getElementById('customDropdownContainer'); if (existingContainer) { existingContainer.remove(); } // Create new dropdown container const container = document.createElement('div'); container.id = 'customDropdownContainer'; container.classList.add('customDropdownContainer'); const optionContainer = document.createElement('div'); optionContainer.id = 'customDropdownOptionContainer'; optionContainer.classList.add('customDropdownOptionContainer'); container.appendChild(optionContainer); // Populate menu with select options const optionEmptyStart = document.createElement('div'); optionEmptyStart.classList.add('customDropdownEmptyOption'); optionContainer.appendChild(optionEmptyStart); const options = select.options; for (let i = 0; i < options.length; i++) { const option = document.createElement('div'); option.classList.add('customDropdownOption'); if (i !== 0) { option.innerHTML = options[i].text; // Select the option and close the dropdown option.addEventListener('click', function () { select.selectedIndex = i; buttonText.innerHTML = options[i].text; // Trigger change event to retain functionality const event = new Event('change'); select.dispatchEvent(event); container.remove(); }); optionContainer.appendChild(option); } } const optionEmptyEnd = document.createElement('div'); optionEmptyEnd.classList.add('customDropdownEmptyOption'); optionContainer.appendChild(optionEmptyEnd); // Append dropdown to body and position it document.body.appendChild(container); const zoomFactor = parseFloat(document.body.style.zoom) || 1; const rectSelect = newSelectButton.getBoundingClientRect(); container.style.position = 'absolute'; container.style.top = `${(rectSelect.top + window.scrollY + 2) / zoomFactor}px`; container.style.left = `${(rectSelect.left + window.scrollX - 2) / zoomFactor}px`; container.style.width = `${newSelectButton.offsetWidth / zoomFactor}px`; // Close dropdown when clicking outside document.addEventListener('click', function closeDropdown(event) { if (!container.contains(event.target) && event.target !== newSelectButton) { container.remove(); document.removeEventListener('click', closeDropdown); } }); }); } //initiate dropdown menu replacement if (getURL.includes(subString)) { const selects = document.querySelectorAll('select'); selects.forEach(select => { substituteSelect(select); }); } }); </script>
CSS: //hide stock// .variant-select-wrapper { // display: none !important; padding: 0 !important; } .variant-select-wrapper::before { display: none !important; } .newSelectButton{ width: 100%; height: 100%; cursor: pointer; padding-left: 10px; letter-spacing: 1.2px; padding-top: 5px; padding-bottom: 5px; margin-top: 10px; margin-bottom: 10px; } .newSelectButtonText { pointer-events: none; } .customDropdownContainer { background-image: linear-gradient(to bottom, rgba(255,191,0,0) 0%, rgba(255,191,0,1) 10%, rgba(255,191,0,1) 90%, rgba(255,191,0,0) 100%); z-index: 1000; position: absolute; z-index: 9999; border-left: solid 2px; border-right: solid 2px; border-image: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12%, rgba(0,0,0,1) 87%, rgba(0,0,0,0) 100%); border-image-slice: 1; } .customDropdownOption { font-size: 1.4em; padding-top: 3px; padding-bottom: 3px; letter-spacing: 1.2px; padding-left: 10px; padding-right: 5px; cursor: pointer; } .customDropdownOption:hover { color: var(--color1); background: #000000; } .customDropdownEmptyOption { height: 27px; } .option-select-gear { background-image: url('https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/3b177fcd-72b2-462f-a563-e73fd964de8f/Gearicon1.png'); border: 0px !important; width: 25px !important; height: 25px !important; left: -35px; position: absolute; pointer-events: none; } .spinner { background-repeat: no-repeat; background-position: center; background-size: contain; animation: rotator 33s linear infinite; }
Adjusting zoom for lower resolution screens
I have been developing this website on a 4k monitor. Whenever I test on smaller screens, specifically the ones with lower resolution, such as office gear, laptops, or older hardware, I got the feeling that everything becomes squished. That was until now, because I devised a not so elegant solution to simply zoom everything out on lower resolutions. This will not fix the tablet view, it will not push the site into mobile view, it will simply adjust zoom based on resolution. There is a sure way to check if we are not on mobile according to squarespace, and if we are indeed not on mobile, the zoom factor will be adjusted for lower resolutions. With lower resolutions I mean screens which are generally less that 2200 pixels wide, because the standard page width is 2200px. Please note that this code does not work in the footer, it has to be in the header code injection.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { //mobile mode check const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); //adjust zoom based on resolution if (!isTouch) { function adjustZoom() { const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; console.log(screenWidth); let zoomFactor = 1; if (screenWidth <= 1024) { zoomFactor = 0.67; } else if (screenWidth <= 1366) { zoomFactor = 0.80; } else if (screenWidth <= 1920) { zoomFactor = 0.90; } else if (screenWidth >= 2560) { zoomFactor = 1; } document.body.style.zoom = zoomFactor; } adjustZoom(); window.addEventListener('resize', adjustZoom); //costly, optional, good for testing } }); </script>
CSS:
(In September 2024:)
1. Mobile Resolutions (Portrait and Landscape)
Mobile devices often have resolutions around 300-800 pixels in width.
iPhone SE (1st gen), iPhone 5, iPhone 5S (Portrait) - Very small, older models: Width: 320px, Height: 568px
iPhone 6/7/8, iPhone SE (2nd gen) (Portrait): Width: 375px, Height: 667px
iPhone X, iPhone 11 Pro, iPhone 12 Mini (Portrait): Width: 375px, Height: 812px
Google Pixel 4, Samsung Galaxy S10 (Portrait): Width: 412px, Height: 869px
iPhone 6/7/8, Google Pixel 4 (Landscape): Width: 667px, Height: 375px
iPhone X, iPhone 11 Pro, Samsung Galaxy S10 (Landscape): Width: 812px, Height: 375px
Samsung Galaxy Fold (Folded) - Large, experimental device: Width: 720px, Height: 1280px
iPhone 16 Plus, newest model with 460 pixels per inch : Width: 2796px, Height: 1290px
2. Tablet Resolutions
Tablets tend to range between 768-1280 pixels in width, both in portrait and landscape.
iPad Mini (Portrait) - Older and smaller iPads: Width: 768px, Height: 1024px
iPad Mini (Landscape): Width: 1024px, Height: 768px
iPad Pro 11-inch (Portrait): Width: 834px, Height: 1194px
iPad Pro 12.9-inch (Portrait) : Width: 1024px, Height: 1366px
Microsoft Surface Pro 7 (Landscape) - (Common Windows-based tablets): Width: 1280px, Height: 800px
3. Laptop and Desktop Resolutions
Laptops and desktops have a wide range of resolutions, with common widths from 1280px to 3840px.
Small Laptops (13") - Common entry-level laptops: Width: 1366px, Height: 768px
Mid-sized Laptops (15") - Standard for many laptops: Width: 1440px, Height: 900px
Larger Laptops (17") - High-end, gaming, or design-focused laptops: Width: 1600px, Height: 900px
Full HD Laptops/Monitors - The most common resolution for both laptops and monitors: Width: 1920px, Height: 1080px
Quad HD Laptops/Monitors - Used by gamers, designers, and productivity users: Width: 2560px, Height: 1440px
Ultra HD 4K Monitors - For high-end desktops and professional displays: Width: 3840px, Height: 2160px
Ultra-wide Monitors (21:9 Aspect Ratio) - For productivity, gaming, and creative work: Width: 3440px, Height: 1440px
Super Ultra-wide Monitors (32:9 Aspect Ratio) - Extremely wide setups: Width: 5120px, Height: 1440px
4. TV Resolutions
While not typical for web design, some users access websites on TV screens. Here's a list of TV resolutions.
HD Ready (720p) - Lower-end HDTVs: Width: 1280px, Height: 720px
Full HD (1080p) - Most “common” TV resolution: Width: 1920px, Height: 1080px
4K Ultra HD TVs - Common on modern high-end televisions: Width: 3840px, Height: 2160px
8K Ultra HD TVs - Cutting-edge TVs, still rare: Width: 7680px, Height: 4320px
5. Summary of Key Screen Resolutions:
Mobile (Small): 320x568 (older phones like iPhone SE 1st gen)
Mobile (Medium): 375x667 (standard iPhones and Android devices)
Mobile (Large): 412x869 (large Android devices like Google Pixel)
Tablet (Small): 768x1024 (small iPads)
Tablet (Large): 1024x1366 (iPad Pro)
Laptop (Small): 1366x768 (small laptops)
Laptop/Monitor (Full HD): 1920x1080 (common laptops and monitors)
Laptop/Monitor (Quad HD): 2560x1440 (high-end displays)
Desktop Monitor (4K): 3840x2160 (professional and high-end monitors)
Ultra-wide Monitors: 3440x1440 (extra-wide productivity monitors)
In other words, if any squarespace affiliates are reading this, please allow us to control the break point for desktop/tablet/mobile (with a single variable), and bring the tablet editing view back.
User Profile page with firebase - the dossier
I decided to give my users a dedicated page to edit their profile, to make things more appealing and have more information to personalize their experience down the line. The result is the dossier page, where signed in users can edit their personal information, such as name, gender, code name, password and other things. I gave it a theme of a secret agent’s profile, mixed with esoteric symbolism and fantasy game appeal, to fit the theme of the website. Users can write their own cover identity and will advance in rank every month automatically. Every rank is not just a number but has an assigned name. Also users can write out their expertise. Apart from that the user’s e-mail is displayed as handle and there is a briefing section, these two, along with the rank are not editable by the user. The command brief used to be edited by me manually in the firebase console, a little something I thought would be fun to do randomly, to encourage users and make things more personal. However, as of the last update it is automated and changes roughly every half a month. Also users can now select an avatar, with a selection per gender. The HTML is the whole dossier, I didn’t use squarespace for this, apart from embedding the result on the page, in place of a text block.
HTML: <div id="dossier-MainContainer"> <div id="dossier-Container"> <div id="dossier-profileImageContainer"> <div id="dossier-profileImageInnerContainer"> <div id="dossier-profileImage-left" class="dossier-profileImage-control left"></div> <div id="dossier-profileImage" class="dossier-profileImage"></div> <div id="dossier-profileImage-right" class="dossier-profileImage-control right"></div> </div> </div> <div id="dossier-handleContainer" class="dossier-dataContainer"> <div id="dossier-handleDetail" class="dossier-detail">Handle:</div> <input type="text" id="dossier-handle-input" class="dossier-input" placeholder="classified" readonly/> </div> <div id="dossier-rankContainer" class="dossier-dataContainer"> <div id="dossier-rankDetail" class="dossier-detail">Rank:</div> <input type="text" id="dossier-rank-input" class="dossier-input" placeholder="classified" readonly/> </div> <div id="dossier-codenameContainer" class="dossier-dataContainer"> <div id="dossier-codenameDetail" class="dossier-detail">Code Name:</div> <input type="text" id="dossier-codename-input" class="dossier-input" maxlength="18" placeholder="classified" /> </div> <div id="dossier-realnameContainer" class="dossier-dataContainer"> <div id="dossier-realnameDetail" class="dossier-detail">Real Name:</div> <input type="text" id="dossier-realname-input" class="dossier-input" maxlength="50" placeholder="classified" /> </div> <div id="dossier-passwordContainer" class="dossier-dataContainer"> <div id="dossier-passwordDetail" class="dossier-detail">Password:</div> <input type="text" id="dossier-password-input" class="dossier-input" maxlength="18" placeholder="classified" /> </div> <div id="dossier-genderContainer" class="dossier-dataContainer"> <div id="dossier-genderDetail" class="dossier-detail">Gender:</div> <div class="dossier-OptionContainer"> <div id="dossier-male" class="dossier-RadioButton male"></div> <div class="dossier-radioDetail">Male</div> </div> <div class="dossier-OptionContainer"> <div id="dossier-female" class="dossier-RadioButton female"></div> <div class="dossier-radioDetail">Female</div> </div> <div class="dossier-OptionContainer"> <div id="dossier-noGender" class="dossier-RadioButton classified"></div> <div class="dossier-radioDetail">Classified</div> </div> </div> <div id="dossier-coverContainer" class="dossier-dataContainer"> <div id="dossier-coverDetail" class="dossier-detail">Cover Identity:</div> <textarea id="dossier-cover-text" class="dossier-input dossier-text" maxlength="500" placeholder="classified">Classified</textarea> </div> <div id="dossier-expertiseContainer" class="dossier-dataContainer"> <div id="dossier-expertiseDetail" class="dossier-detail">Expertise:</div> <textarea id="dossier-expertise-text" class="dossier-input dossier-text" maxlength="500" placeholder="classified">Classified</textarea> </div> <div id="dossier-notesContainer" class="dossier-dataContainer"> <div id="dossier-notesDetail" class="dossier-detail">Command Brief:</div> <textarea id="dossier-notes-text" class="dossier-input dossier-text" placeholder="classified" readonly>Classified</textarea> </div> <div id="dossier-buttonContainer" class="dossier-buttonContainer"> <button id="dossier-assignButton" class="sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element sqs-block-button-container">Update</button> </div> <div id="dossier-recruitedContainer" class="dossier-dataContainer"> <div id="dossier-recruitedText" class="dossier-simpleText">Recruited: Classified</div> <div id="dossier-evaluationText" class="dossier-simpleText">Rank Evaluation</div> <div id="dossier-briefingText" class="dossier-simpleText">Next Briefing</div> </div> </div> </div>
JAVASCRIPT: <script type="module"> // Initialize Firebase import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, signOut, onAuthStateChanged, updatePassword, reauthenticateWithCredential, EmailAuthProvider } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, collection, setDoc, writeBatch, deleteDoc, getDocs, query, serverTimestamp, where, doc, getDoc } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js'; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); //main stuff document.addEventListener('DOMContentLoaded', function() { const dossierMainContainer = document.getElementById('dossier-MainContainer'); const subString = '/accountdossier'; const getURL = window.location.href; if (getURL.includes(subString)) { const dossierEmbedBlock = document.getElementById('block-yui_3_17_2_1_1726225293920_42083'); const dossierEmbedBlockText = dossierEmbedBlock.querySelector('p'); const profileImage = document.getElementById('dossier-profileImage'); const profileImageInnerContainer = document.getElementById('dossier-profileImageInnerContainer'); const profileImageControlLeft = document.getElementById('dossier-profileImage-left'); const profileImageControlRight = document.getElementById('dossier-profileImage-right'); dossierEmbedBlockText.textContent = ''; dossierEmbedBlock.appendChild(dossierMainContainer); //initials let isMaster = false; let handle = ''; let codename = ''; let realName = ''; let gender = 'classified'; let memberSince = ''; let lastCheckIn = ''; let rank = ''; let rankNumber = 1; let rankTimeRemaining = 30; let oldRank = ''; let cover = ''; let expertise = ''; let notes = ''; let profilePic = ''; let profileImagesSelection; let currentImageIndex = 0; let accessGranted = false; let passwordUpdated = false; let codenameUpdated = false; let realNameUpdated = false; let genderUpdated = false; let coverUpdated = false; let expertiseUpdated = false; let codenameUpdateErrorMsg = 'Error updaing codename'; let updateErrorMsg = 'Error updaing dossier'; let passwordUpdateErrorMsg = 'Error updating password'; const forbiddenCodenames = ['master', 'user', 'admin', 'superuser', 'root', 'administrator', 'lev polivanov', 'manager', 'overseer', 'president', 'secret', 'stranger', 'unknown', 'mystery', 'archmage', 'supervisor', 'director', 'handler']; //ranks have been redacted, there are 33 of them const rankMap = new Map([ [1, 'Analyst'], ... [33, 'Arrowhead'] ]); //notes also redacted, there are 66 of them const notesMap = new Map([ [1, 'Welcome recruit! Your purpose is your own in these lower ranks, I trust that for now you will get accustomed to the company and the routine tasks. I like my coffee black, with milk, a modicum of sugar, no milk, only latte, sugar free, but with a pinch of caramel. Should you have questions, feel free to chat with other agents.'], ... [66, 'You do not miss, you do not waver. As arrowhead you have advanced to the league of agents which are not technically alive, nor really dead. You will exist inbetween, but your job will remain unchanged. You know what to do, keep it up. This is the final rank for now, agent. I trust your dormant status will elude us.'] ]); //images redacted, this is just an example const maleProfileImages = [ 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/1a11687d-4bb8-4c20-a5e7-4f02a939d5a8/AccountIcon_StandardMale1.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/8b5fbd79-8f8c-4d41-b888-ebeebdcc6d3c/AccountIcon_Male2.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/2f2a1ef6-0568-4706-acb4-e5f4195d9412/AccountIcon_Male3.png' ]; const femaleProfileImages = [ 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/593c8052-a087-4ac4-9213-6759f7f7be7c/AccountIcon_StandardFemale1.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/72b4be0c-6c20-4317-a5fa-a9152abbafb7/AccountIcon_Female2.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/8673c621-f9df-4333-baea-23c1985fee5d/AccountIcon_Female3.png' ]; const neutralProfileImages = [ 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/a31bed02-e0b6-48d6-9d81-8edead9fae30/AccountIcon_StandardUnknown1.png' ]; //show and hide popover let hideTimeout; var popover = document.getElementById('custom-popover'); var popoverMessage = document.getElementById('popover-message'); function showPopover(message) { popoverMessage.textContent = message; popover.classList.add('show'); popover.style.pointerEvents = 'auto'; clearTimeout(hideTimeout); hideTimeout = setTimeout(function() { popover.classList.remove('show'); popover.style.pointerEvents = 'none'; }, 3000); } //to assign new rank every month function calculateMonthsSince(memberSince) { const currentDate = new Date(); const accountDate = new Date(memberSince); const yearsDiff = currentDate.getFullYear() - accountDate.getFullYear(); const monthsDiff = currentDate.getMonth() - accountDate.getMonth(); return (yearsDiff * 12) + monthsDiff; } function getRank(memberSince) { const monthsSince = calculateMonthsSince(memberSince); rankNumber = Math.min(Math.floor(monthsSince / 1) + 1, 33); const rankString = rankNumber + ': ' + rankMap.get(rankNumber); if (oldRank !== rankString) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('You have been promoted to ' + rankMap.get(rankNumber) + ', effective immediately'); } return rankString; } //to assign new notes every two weeks function calculateWeeksSince(memberSince) { const currentDate = new Date(); const accountDate = new Date(memberSince); const timeDiff = currentDate - accountDate; const weeksDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24 * 7)); return weeksDiff; } function getNotes(memberSince) { const weeksSince = calculateWeeksSince(memberSince); const noteNumber = Math.min(Math.floor(weeksSince / 2) + 1, 66); const notesString = notesMap.get(noteNumber); return notesString; } //to calculate remining time until rank update function calculateTimeDifference(date1, date2) { const oneMinute = 60 * 1000; // Milliseconds in a minute const oneHour = 60 * oneMinute; // Milliseconds in an hour const oneDay = 24 * oneHour; // Milliseconds in a day const timeDifference = date2 - date1; const days = Math.floor(timeDifference / oneDay); const hours = Math.floor((timeDifference % oneDay) / oneHour); const minutes = Math.floor((timeDifference % oneHour) / oneMinute); return { days, hours, minutes }; } function timeUntilNextRank(memberSince) { const currentDate = new Date(); const accountDate = new Date(memberSince); let nextRankUpdateYear = currentDate.getFullYear(); let nextRankUpdateMonth = currentDate.getMonth(); let nextRankUpdateDate = new Date(nextRankUpdateYear, nextRankUpdateMonth, accountDate.getDate()); if (currentDate >= nextRankUpdateDate) { nextRankUpdateDate.setMonth(nextRankUpdateDate.getMonth() + 1); } const timeRemaining = calculateTimeDifference(currentDate, nextRankUpdateDate); const daysLeftText = timeRemaining.days === 1 ? 'day' : 'days'; const hoursLeftText = timeRemaining.hours === 1 ? 'hour' : 'hours'; const minutesLeftText = timeRemaining.minutes === 1 ? 'minute' : 'minutes'; return `★ Next rank evaluation is in ${timeRemaining.days} ${daysLeftText}, ${timeRemaining.hours} ${hoursLeftText}, and ${timeRemaining.minutes} ${minutesLeftText}. ★`; } //to get time until next notes briefing function timeUntilNextNote(memberSince) { const currentDate = new Date(); const accountDate = new Date(memberSince); let nextNoteUpdateDate = new Date(accountDate); let currentMonth = currentDate.getMonth(); let daysInMonth = new Date(currentDate.getFullYear(), currentMonth + 1, 0).getDate(); let halfMonth = Math.ceil(daysInMonth / 2); let timeSinceStart = Math.floor((currentDate - accountDate) / (1000 * 60 * 60 * 24 * halfMonth)) * halfMonth; nextNoteUpdateDate.setDate(accountDate.getDate() + timeSinceStart + halfMonth); if (currentDate >= nextNoteUpdateDate) { nextNoteUpdateDate.setDate(nextNoteUpdateDate.getDate() + halfMonth); } const timeRemaining = calculateTimeDifference(currentDate, nextNoteUpdateDate); const daysLeftText = timeRemaining.days === 1 ? 'day' : 'days'; const hoursLeftText = timeRemaining.hours === 1 ? 'hour' : 'hours'; const minutesLeftText = timeRemaining.minutes === 1 ? 'minute' : 'minutes'; return `★ Next briefing is in ${timeRemaining.days} ${daysLeftText}, ${timeRemaining.hours} ${hoursLeftText}, and ${timeRemaining.minutes} ${minutesLeftText}. ★`; } //to set default gender profile pictures function setProfileImage() { if (gender === 'male') { profileImagesSelection = maleProfileImages; } else if (gender === 'female') { profileImagesSelection = femaleProfileImages; } else { profileImagesSelection = neutralProfileImages; } if (!profilePic) { profilePic = profileImagesSelection[0]; } profileImage.style.backgroundImage = `url(${profilePic})`; return profilePic; } //change profile image functionality function showImageControls(){ profileImageControlRight.classList.add('visible'); profileImageControlLeft.classList.add('visible'); } function hideImageControls(){ profileImageControlRight.classList.remove('visible'); profileImageControlLeft.classList.remove('visible'); } profileImageInnerContainer.addEventListener('mouseenter', () => { showImageControls(); }); profileImageInnerContainer.addEventListener('mouseleave', () => { hideImageControls(); }); profileImage.addEventListener('click', () => { showImageControls(); }); profileImageInnerContainer.addEventListener('blur', () => { hideImageControls(); }); profileImageControlRight.addEventListener('click', () => { currentImageIndex = (currentImageIndex + 1) % profileImagesSelection.length; profilePic = profileImagesSelection[currentImageIndex]; profileImage.style.backgroundImage = `url(${profilePic})`; }); profileImageControlLeft.addEventListener('click', () => { currentImageIndex = (currentImageIndex - 1 + profileImagesSelection.length) % profileImagesSelection.length; profilePic = profileImagesSelection[currentImageIndex]; profileImage.style.backgroundImage = `url(${profilePic})`; }); //custom radio buttons functionality const radioButtons = document.querySelectorAll('.dossier-RadioButton'); function handleRadioClick(event) { radioButtons.forEach(button => { button.classList.remove('selected'); }); event.currentTarget.classList.add('selected'); const selectedClass = event.currentTarget.classList[1]; switch (selectedClass) { case 'male': gender = 'male'; break; case 'female': gender = 'female'; break; case 'classified': gender = 'classified'; break; default: gender = 'classified'; break; } setProfileImage(); profilePic = profileImagesSelection[0]; setProfileImage(); } radioButtons.forEach(button => { button.addEventListener('click', handleRadioClick); }); //if logged in onAuthStateChanged(auth, async (user) => { if (user) { handle = user.email; memberSince = user.metadata.creationTime; const userDocRef = doc(db, 'users', user.uid); try { const userDoc = await getDoc(userDocRef); if (userDoc.exists()) { const userData = userDoc.data(); isMaster = userData.Master || false; codename = userData.codename || ''; lastCheckIn = userData.lastStatusChange || 'never'; realName = userData.name || ''; gender = userData.gender || 'classified'; profilePic = userData.avatar || setProfileImage(); cover = userData.cover || ''; expertise = userData.expertise || ''; notes = isMaster ? userData.notes : getNotes(memberSince); //admins briefing is set manually oldRank = userData.rank || 'classified'; rank = isMaster ? userData.rank : getRank(memberSince); //admins rank is set manually accessGranted = true; //when data is retrieved fill in the dossier setProfileImage(); const handleInput = document.getElementById('dossier-handle-input'); handleInput.value = handle; const rankInput = document.getElementById('dossier-rank-input'); rankInput.value = rank; const codenameInput = document.getElementById('dossier-codename-input'); codenameInput.value = codename; const realnameInput = document.getElementById('dossier-realname-input'); realnameInput.value = realName; const passwordInput = document.getElementById('dossier-password-input'); passwordInput.value = '★★★★★★★★'; const coverText = document.getElementById('dossier-cover-text'); coverText.value = cover; const expertiseText = document.getElementById('dossier-expertise-text'); expertiseText.value = expertise; const notesText = document.getElementById('dossier-notes-text'); notesText.value = notes; const recruitedText = document.getElementById('dossier-recruitedText'); recruitedText.innerHTML = '★ Recruited on ' + memberSince + '. ★'; const evaluationText = document.getElementById('dossier-evaluationText'); timeUntilNextRank(memberSince); evaluationText.innerHTML = timeUntilNextRank(memberSince); const briefingText = document.getElementById('dossier-briefingText'); briefingText.innerHTML = timeUntilNextNote(memberSince); radioButtons.forEach(button => { const selectedClass = button.classList[1]; if (gender == selectedClass){ button.classList.add('selected'); return; } }); async function handleDossierUpdate(event) { event.preventDefault(); // PASSWORD CHANGE const newPassword = passwordInput.value.trim(); if (newPassword.includes('★')) { passwordUpdated = true; } else if (newPassword) { try { await updatePassword(auth.currentUser, newPassword); passwordUpdated = true; } catch (error) { console.error('Error updating password:', error); passwordUpdateErrorMsg = 'Error updating password'; if (error.code === 'auth/requires-recent-login') { const credential = EmailAuthProvider.credential( auth.currentUser.email, prompt('Please re-enter your current password for security reasons:') ); try { await reauthenticateWithCredential(auth.currentUser, credential); await updatePassword(auth.currentUser, newPassword); passwordUpdated = true; } catch (reauthError) { console.error('Error during re-authentication:', reauthError); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Error during re-authentication.'); } } } } else { passwordUpdated = false; passwordUpdateErrorMsg = 'Password was empty'; } //CODENAME CHANGE const newCodename = codenameInput.value.trim(); if (newCodename) { try { if (newCodename.length > 18) { popoverMessage.style.color = "#ea4b1a"; showPopover('Code name must be 18 characters or less.'); } else if (isMaster === false && forbiddenCodenames.includes(newCodename.toLowerCase())) { updateErrorMsg = 'This code name is already assigned to an agent of a higher rank.'; } else { const codenameQuery = query(collection(db, 'users'), where('codename', '==', newCodename)); const querySnapshot = await getDocs(codenameQuery); if (!querySnapshot.empty && userData.codename !== newCodename) { codenameUpdateErrorMsg = 'This code name is already assigned to another agent'; } else { if (userData && userData.lastCodenameChange) { const lastChangeTime = userData.lastCodenameChange.toDate(); const oneWeekInMillis = 7 * 24 * 60 * 60 * 1000; const now = new Date(); if ((now - lastChangeTime) < oneWeekInMillis && isMaster === false) { codenameUpdateErrorMsg = 'You can only change your codename once a week'; } } await setDoc(userDocRef, { codename: newCodename, lastCodenameChange: serverTimestamp() }, { merge: true }); codenameUpdated = true; } } } catch (error) { console.error('Error updating codename:', error); codenameUpdateErrorMsg = 'Failed to update codename'; } } //REAL NAME CHANGE try { const newRealName = realnameInput.value.trim(); if (newRealName) { await setDoc(userDocRef, { name: newRealName }, { merge: true }); realNameUpdated = true; } } catch (error) { console.error('Error updating real name:', error); updateErrorMsg = 'Failed to update real name.'; } //GENDER CHANGE try { if (gender) { await setDoc(userDocRef, { gender: gender }, { merge: true }); genderUpdated = true; } } catch (error) { console.error('Error updating gender:', error); updateErrorMsg = 'Failed to update gender.'; } //COVER CHANGE try { const newCover = coverText.value.trim(); if (newCover) { await setDoc(userDocRef, { cover: newCover }, { merge: true }); coverUpdated = true; } } catch (error) { console.error('Error updating cover:', error); updateErrorMsg = 'Failed to update cover identity.'; } //EXPERTISE CHANGE try { const newExpertise = expertiseText.value.trim(); if (newExpertise) { await setDoc(userDocRef, { expertise: newExpertise }, { merge: true }); expertiseUpdated = true; } } catch (error) { console.error('Error updating expertise:', error); updateErrorMsg = 'Failed to update expertise.'; } //UPDATE RANK IN DATABASE try { if (rank) { await setDoc(userDocRef, { rank: rank }, { merge: true }); } } catch (error) { console.error('Error updating rank:', error); } //UPDATE PROFILE PICTURE IN DATABASE try { if (rank) { await setDoc(userDocRef, { avatar: profilePic }, { merge: true }); } } catch (error) { console.error('Error updating rank:', error); } // Final message after all updates if (!codenameUpdated) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover(codenameUpdateErrorMsg + ', other changes have been approved'); } else if (!realNameUpdated || !genderUpdated || !coverUpdated || !expertiseUpdated) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover(updateErrorMsg); } else if (!passwordUpdated) { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover(passwordUpdateErrorMsg + ', other changes have been approved.'); } else { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('Dossier changes have been approved'); } } const updateButton = document.getElementById('dossier-assignButton'); updateButton.addEventListener('click', handleDossierUpdate); } } catch (error) { accessGranted = false; console.error('Error fetching user data:', error); $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('This dossier appears to be highly classified.'); } //if not logged in } else { window.location.href = '/login'; } }); //exit } else { dossierMainContainer.remove(); } }); </script>
CSS: #dossier-MainContainer { width: 100%; max-width: 2200px; display: flex; justify-content: center; } #dossier-Container { min-width: 50%; max-width: 96%; } #dossier-profileImageContainer { display: flex; justify-content: center; } #dossier-profileImageInnerContainer { display: flex; justify-content: center; align-items: center; cursor: default; margin-bottom: 30px; } #dossier-profileImage { height: 200px; width: 200px; margin-right: 0px; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/a31bed02-e0b6-48d6-9d81-8edead9fae30/AccountIcon_StandardUnknown1.png); background-size: contain; background-position: center; background-repeat: no-repeat; } .dossier-dataContainer { width: 100%; } .dossier-profileImage-control { transition: all 1s ease; opacity: 0; border-right: 3px solid black !important; border-top: 3px solid black !important; border-image: linear-gradient(45deg, rgba(0,0,0,0) 50%, rgba(0,0,0,1) 100%); border-image-slice: 1; background-size: contain; background-position: center; background-repeat: no-repeat; width: 50px; height: 50px; margin-top: -30px; margin-left: 40px; margin-right: 40px; cursor: pointer; } .dossier-profileImage-control.visible { opacity: 1; } #dossier-profileImage-left { transform: rotate(-135deg); } #dossier-profileImage-right { transform: rotate(45deg); } .dossier-input { width: 100%; background: transparent; border-top: 0; padding-bottom: 5px; padding-left: 10px; padding-right: 10px; outline: none !important; letter-spacing: 1.4px; } .dossier-detail { padding-left: 12px; margin-top: 20px; letter-spacing: 2px; text-transform: uppercase; } .dossier-text { resize: vertical; min-width: 100% !important; min-height: 100px !important; height: 100px; padding-left: 10px; padding-right: 10px; padding-bottom: 10px; letter-spacing: 1.4px; line-height: 27px; } #dossier-genderContainer { border-bottom: solid 2px; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 18%, rgba(0,0,0,1) 30%, rgba(0,0,0,0) 50%); border-image-slice: 1; letter-spacing: 1.4px; padding-bottom: 5px; } .dossier-OptionContainer { display: flex; align-items: center; } .dossier-RadioButton { width: 25px; height: 25px; margin-right: 10px; margin-left: 10px; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/50d8adb5-c7f1-4c9f-b304-73ecf7dda6ac/Targeticon_unselected1.png); background-size: contain; background-position: center; background-repeat: no-repeat; cursor: pointer; margin-bottom: 6px; } .dossier-RadioButton.selected { background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/68d3d7c4-1097-4510-8932-df745ace48ce/Targeticon1.png); background-size: contain; background-position: center; background-repeat: no-repeat; } #dossier-buttonContainer { display: flex; width: 100%; justify-content: flex-end; } #dossier-assignButton { width: 50%; margin-top: 20px; margin-right: -20px; margin-bottom: 20px; text-align: right; letter-spacing: 3px !important; } #dossier-recruitedContainer { text-align: center; margin-top: 20px; letter-spacing: 2px !important; } #dossier-handle-input, #dossier-rank-input, #dossier-notes-text { color: var(--color3); } //mobile dossier @media only screen and (max-width:790px) { #dossier-assignButton { width: 100%; } #dossier-Container { min-width: 50%; max-width: 100%; padding-right: 20px; } #dossier-profileImage { height: 150px; width: 150px; margin-right: -15px; } }
It looks very pretty, I encourage you to make an account and have a look. You can access the dossier through your account page.
xml product sheet generator for google merchant center
Below you will find code that I use to generate my product sheets to be used by google. I simply click generate, then download, and upload the file directly to the google merchant center. Alternatively you can also copy the generated text to clipboard. The XML is correctly formatted to be accepted by google. Upon upload it does not produce any errors, and the updated product list is flagged “approved” for each product - success. It can now be used by google for search and advertising purposes. However, I am still discovering how the merchant center operates and I might add more fields to the generated XML down the line.
If you plan to use this for your own store, a lot will have to be changed to fit your products. For example, the category map will have to be populated with your own categories and you will have to look up which google code is applicable for each category (link in the code). Of course your web shop URL will be different, and a lot of product information, such as stock availability may have to be adjusted.
As for how the code works - I fetch a JSON of my shop page, then populate the XML structure I create, with the data from the JSON, along the way some manipulation is done to the text to be read correctly in the XML format. Fetching the exact color information proved troublesome for some products, I suggest adding designated tags for each product at a predefined position. For example, you can add a color as the first tag for each product, and then fetch the first product tag to fill in the google color tag. The same goes for product type which then can be tag number two. This will allow for a more granular approach, but will also require consistency and individual product editing, exactly what I didn’t want to do retrospectively. Note that if your product images include text, they may be flagged by google, but in a few days the flags will be resolved, if the text doesn’t contain advertising or any unsafe content.
HTML: <textarea id="xmlOutput" placeholder="Your generated XML will appear here..."></textarea>
JAVASCRIPT: document.addEventListener('DOMContentLoaded', function () { const xmlTextArea = document.getElementById('xmlOutput'); const getURL = window.location.href; const subString = "/productsheet"; if (getURL.includes(subString)) { const generateButton = document.getElementById('block-yui_3_17_2_1_1725973683359_9167'); const copyButton = document.getElementById('block-74181b5749eca926bf14'); const downloadButton = document.getElementById('block-36b5b1de79a63cd630de'); const placeBlock = document.getElementById('block-9d9632b0692ebb10f751'); const placeBlockText = placeBlock.querySelector('p'); placeBlock.appendChild(xmlTextArea); placeBlockText.textContent = ''; //initialize popover let hideTimeout; var popover = document.getElementById('custom-popover'); var popoverMessage = document.getElementById('popover-message'); function showPopover(message) { popoverMessage.textContent = message; popover.classList.add('show'); popover.style.pointerEvents = 'auto'; clearTimeout(hideTimeout); hideTimeout = setTimeout(function() { popover.classList.remove('show'); popover.style.pointerEvents = 'none'; }, 3000); } // reduced List of all (example) country codes we ship to const shippingCountries = [ { country: 'AE' }, // United Arab Emirates { country: 'MC' }, // Monaco { country: 'LU' } // Luxembourg ]; //to map product category IDs to Google category IDs and get category name //https://www.google.com/basepages/producttype/taxonomy-with-ids.en-US.txt function getGoogleCategoryInfo(productCategoryId, categoryIds) { const categoryMap = { 'Prints': '500044', 'Clothing': '1604', 'Accessories': '166', 'Unique': '216', 'Other': '8' }; let googleCategoryId = '5710'; // Default, Hobbies & Creative Arts let googleCategoryName = 'Hobbies and Creative Arts'; // Default category name categoryIds.forEach(category => { if (category.id === productCategoryId) { googleCategoryId = categoryMap[category.displayName] || googleCategoryId; googleCategoryName = category.displayName || googleCategoryName; } }); return { googleCategoryId, googleCategoryName }; } //to clean the product description for xml function cleanDescription(description) { const tempElement = document.createElement('textarea'); tempElement.innerHTML = description; let plainText = tempElement.value; plainText = plainText.replace(/<\/?[^>]+(>|$)/g, ""); plainText = plainText.replace(/<br\s*\/?>/gi, "\n"); plainText = plainText //.replace(/•/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return plainText.trim(); } //Fetch the shop JSON data const shopJsonURL = 'https://www.polivantage.com/shop?format=json-pretty'; async function fetchProductData() { try { const response = await fetch(shopJsonURL); const data = await response.json(); return { products: data.items, categories: data.nestedCategories.categories || [] }; } catch (error) { console.error('Error fetching product data:', error); return { products: [], categories: [] }; } } //Build the XML feed //do not use any special characters like & in the title and desciption! function buildXmlFeed(products, categories) { const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?> <rss xmlns:g="http://base.google.com/ns/1.0" version="2.0"> <channel> <title>Polivantage Art Shop</title> <link>https://www.polivantage.com/shop</link> <description>Art and Design Services and Concept Products</description> `; let xmlItems = ''; products.forEach(product => { const id = product.id; const title = product.title; const description = cleanDescription(product.excerpt || 'No description available'); const link = `https://www.polivantage.com${product.fullUrl}`; const image_link = product.items[0].assetUrl; const availability = 'in stock'; const price = product.variants[0].priceMoney.value + ' EUR'; const brand = 'Polivantage'; const googleCategoryId = getGoogleCategoryInfo(product.categoryIds[0], categories).googleCategoryId; const type = getGoogleCategoryInfo(product.categoryIds[0], categories).googleCategoryName; const shippingPrice = '0 EUR'; const shippingService = 'Standard'; const language = 'en'; const gender = 'unisex'; const ageGroup = 'adult'; const shippingLabel = 'Free Shipping'; const deliveryTime = '14 days'; const transitTimeLabel = 'EU_Shipped'; const sizes = product.variants.map(variant => variant.attributes.Size || 'M'); const colors = product.variants.map(variant => variant.attributes.Color || 'Black').filter(Boolean).slice(0, 3); // Generate the XML item for this product xmlItems += ` <item> <g:id>${id}</g:id> <g:title>${title}</g:title> <g:description>${description}</g:description> <g:link>${link}</g:link> <g:product_type>${type}</g:product_type> <g:image_link>${image_link}</g:image_link> <g:availability>${availability}</g:availability> <g:price>${price}</g:price> <g:brand>${brand}</g:brand> <g:condition>new</g:condition> <g:google_product_category>${googleCategoryId}</g:google_product_category> <g:identifier_exists>false</g:identifier_exists> <g:language>${language}</g:language> <g:gender>${gender}</g:gender> <g:age_group>${ageGroup}</g:age_group> ${colors.map(color => `<g:color>${color}</g:color>`).join('')} ${sizes.map(size => `<g:size>${size}</g:size>`).join('')} ${shippingCountries.map(config => ` <g:shipping> <g:country>${config.country}</g:country> <g:service>Standard</g:service> <g:price>${shippingPrice}</g:price> <g:shipping_label>${shippingLabel}</g:shipping_label> <g:transit_time_label>${transitTimeLabel}</g:transit_time_label> </g:shipping> `).join('')} </item>`; }); const xmlFooter = ` </channel> </rss>`; return xmlHeader + xmlItems + xmlFooter; } // Generate the final XML async function generateXmlFeed() { const { products, categories } = await fetchProductData(); if (products.length === 0) { alert('No products found or error fetching products.'); return; } const xmlFeed = buildXmlFeed(products, categories); xmlTextArea.value = xmlFeed; } generateButton.addEventListener('click', function (event) { event.preventDefault(); generateXmlFeed(); }); //copy to clipboard button copyButton.addEventListener('click', function (event) { event.preventDefault(); const text = xmlTextArea.value; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ffc700"; showPopover('XML copied to clipboard'); }).catch(err => { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('XML failed to copy'); }); } else { console.error('Clipboard API not supported'); } }); //download file button downloadButton.addEventListener('click', function (event) { event.preventDefault(); const text = xmlTextArea.value; if (text === '') { $('#popoverMessage').off('click'); popoverMessage.style.color = "#ea4b1a"; showPopover('Generate the XML first'); return; } const blob = new Blob([text], { type: 'application/xml' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.href = url; link.download = 'PolivantageProductSheet.xml'; link.click(); URL.revokeObjectURL(url); }); //exit } else { xmlTextArea.remove(); } });
CSS: #block-9d9632b0692ebb10f751 { display: flex; justify-content: center; align-items: center; } #xmlOutput { width: 96%; resize: vertical; min-height: 50vh; background: transparent; }
Display number of visitors on each product page With Firebase
Squarespace does not expose its analytical metrics for front-end use. I had to use firebase once again to achieve this. The end result is that each product page in my shop displays how many visitors are also on that page. You can see this working if you navigate to any product in my shop and look just above the comment’s section and below the buy button. Chance is big that it will say you are the only one here, nevertheless I tried to keep the overhead to a minimum, potentially accommodating a large number of viewers, or potential customers. This involved creating a server side clean-up function which will not be presented here, however you can do some clean-up on the front end as well, should you want it. Some of the front-end initiated clean-up is implemented already. To get to the point - upon entering any product page I create a count display element which is just a div, then there is a bit of code with a mutation observer to embed it in the right place, because my comments section is also embedded dynamically and I needed to insert the counter before the comments. Proceeding, we are creating a unique ID for each visitor per product page and storing that information in the real time database. We also retrieve the information and count the number of logged visitors on the page we are viewing, subsequently displaying that information.
HTML:
JAVASCRIPT: <script type="module"> import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getDatabase, ref, set, remove, serverTimestamp, onValue, get } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-database.js'; const app = initializeApp(firebaseConfig); const db = getDatabase(app); document.addEventListener('DOMContentLoaded', function() { //are we on product page const subString = 'shop/p/'; const getURL = window.location.href; if (getURL.includes(subString)) { // Create the count element const visitorCountMainContainer = document.createElement('div'); visitorCountMainContainer.id = 'visitorCountMainContainer'; const visitorCountContainer = document.createElement('div'); visitorCountContainer.id = 'visitorCountContainer'; const visitorCount = document.createElement('div'); visitorCount.id = 'visitorCount'; visitorCountMainContainer.appendChild(visitorCountContainer); visitorCountContainer.appendChild(visitorCount); //wait for comments to be added then insert the count const productItem = document.querySelector('.ProductItem'); function insertVisitorCount() { const commentsPlace = document.getElementById('commentsMainContainer'); if (productItem && commentsPlace && productItem.contains(commentsPlace)) { productItem.insertBefore(visitorCountMainContainer, commentsPlace); return true; } return false; } const observer = new MutationObserver(() => { if (insertVisitorCount()) { observer.disconnect(); } }); if (productItem) { observer.observe(productItem, { childList: true, subtree: true }); } //generate product and visitor IDs for firebase const productId = getURL.split(subString)[1]; const SESSION_TIMEOUT_MS = 30 * 60 * 1000; //time before we count user as inactive let lastActivityTime = Date.now(); let sessionTimeout; let visitorActive = false; function generateVisitorId() { let visitorId = localStorage.getItem('visitorId'); if (!visitorId) { visitorId = 'visitor-' + Math.random().toString(36).substr(2, 9); localStorage.setItem('visitorId', visitorId); } return visitorId; } const visitorId = generateVisitorId(); const visitorRef = ref(db, `activeVisitors/${productId}/${visitorId}`); async function setVisitorActive() { if (visitorActive === false) { visitorActive = true; try { const expiresAt = Date.now() + SESSION_TIMEOUT_MS; await set(visitorRef, { visitorId: visitorId, lastActive: serverTimestamp(), expiresAt: expiresAt }); resetSessionTimeout(); } catch (error) { console.error("Error setting visitor active: ", error); } } } async function removeVisitor() { visitorActive = false; try { await remove(visitorRef); } catch (error) { console.error("Error removing visitor: ", error); } } function resetSessionTimeout() { visitorActive = true; clearTimeout(sessionTimeout); lastActivityTime = Date.now(); sessionTimeout = setTimeout(() => handleSessionTimeout(), SESSION_TIMEOUT_MS); } async function handleSessionTimeout() { console.log("Visitor inactive, removing session"); await removeVisitor(); } function displayActiveVisitorCount() { const activeVisitorsRef = ref(db, `activeVisitors/${productId}`); get(activeVisitorsRef).then((snapshot) => { const visitorsData = snapshot.val(); const activeVisitors = visitorsData ? Object.keys(visitorsData).length : 0; const activeOtherVisitors = activeVisitors-1; const visitorText = activeOtherVisitors === 0 ? 'You are the only one viewing this product, in the whole world.' : activeVisitors === 1 ? '1 other potential customer is also viewing this product.' : `${activeVisitors} potential customers are viewing this product.`; visitorCount.innerText = visitorText; }); } window.addEventListener('beforeunload', async () => { await removeVisitor(); }); setVisitorActive(); displayActiveVisitorCount(); } }); </script>
CSS: #visitorCountMainContainer { display: flex; justify-content: flex-end; width: 100%; margin-top: -10px; }
Display selected product variant’s associated image in shop
Somehow squarespace does not have this functionality out of the box. It inconsistently, or never displays the product image which is associated with the selected variant. For example, if I select a t-shirt color blue, the thumbnail will not change to the blue shirt. I wanted to remedy this, hence the code below. It still requires some tweaking, and you might want to add CSS for smoother transitions, but it works both on the product page, and on the category page if you followed my blog to enable carousel controls in shop category pages. The main function is filtering the images by the selected variant, subsequently hiding, or showing the images involved. The script involves normalization for different types of “x” characters and inch symbols, “cm”, etc. This ensures that sizes like “M”, or “XL” are filtered, while sizes like 13” and 15”, or 22”x30”, for example, are left to be compared against the image data. This means that a t-shirt size XL, which looks identical to a t-shirt size M on the picture, will not be filtered by size, leaving the filtering open for color options, while products such as pillows and cases, which vary in dimensions, will be size filtered. Theoretically there may be interference when the product has varying sizes and colors, I don’t have such products, so it is up to you to test.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function() { const subString = 'shop'; const subStringTwo = 'shop/p/'; const getURL = window.location.href; function onSelectChange(selectedOption, product){ var imageFound = false; let currentThumbDiv = product.querySelector('.selected'); const allThumbnailDivs = product.querySelectorAll('.ProductItem-gallery-slides-item, .grid-image-wrapper'); const normalizedSelectedOption = selectedOption.trim().toLowerCase().replace(/\s+/g, '-').replace(/"/g, '').replace(/″/g, '').replace(/×/g, 'x'); allThumbnailDivs.forEach(thumbnailDiv => { const allThumbnails = thumbnailDiv.querySelectorAll('img'); let currentThumb = thumbnailDiv.querySelector('.grid-image-cover'); allThumbnails.forEach(thumbnail => { thumbnail.style.transition = 'opacity 0s ease !important'; thumbnail.classList.add('loaded'); thumbnail.style.opacity = '1'; thumbnail.classList.remove('grid-image-hover'); if (getURL.includes(subStringTwo)) { const altText = thumbnail.getAttribute('alt') || ''; const normalizedAltText = altText.trim().toLowerCase().replace(/[\s-_]+/g, '-').replace(/×/g, 'x'); if (normalizedAltText.includes(normalizedSelectedOption)) { if (!imageFound){ imageFound = true; thumbnailDiv.classList.add('selected'); currentThumbDiv = thumbnailDiv; } } else { thumbnailDiv.classList.remove('selected'); currentThumbDiv.classList.add('selected'); } } else { let srcText = thumbnail.getAttribute('src') || ''; srcText = srcText.substring(srcText.lastIndexOf('/') + 1); srcText = srcText.replace(/\.[^/.]+$/, ''); const normalizedSrcText = srcText.trim().toLowerCase().replace(/[\s-_]+/g, '-').replace(/×/g, 'x'); console.log("Comparing selected option: ", normalizedSelectedOption, " with src text: ", normalizedSrcText); if (normalizedSrcText.includes(normalizedSelectedOption)) { if (!imageFound){ imageFound = true; thumbnail.style.display = 'block'; thumbnail.style.opacity = '1'; thumbnail.classList.add('loaded'); thumbnail.classList.add('active'); thumbnail.classList.add('grid-image-selected'); currentThumb = thumbnail; } } else { thumbnail.classList.remove('loaded'); thumbnail.classList.remove('grid-image-selected'); thumbnail.classList.remove('active'); thumbnail.style.display = 'none'; thumbnail.style.opacity = '0'; if (currentThumb){ currentThumb.style.display = 'block'; currentThumb.classList.add('loaded'); currentThumb.classList.add('active'); thumbnail.classList.add('grid-image-selected'); currentThumb.style.opacity = '1'; } } } }); }); } function addSelectEventListeners(product) { let productSelects = product.querySelectorAll('select'); productSelects = Array.from(productSelects).filter(select => { const optionName = select.getAttribute('data-variant-option-name') || ''; if (optionName === 'Size' || optionName === 'Select Size') { const options = Array.from(select.options).map(option => option.value); return options.some(option => option.includes('″') || option.includes('"') || option.includes('cm')); } return true; }); productSelects.forEach(select => { select.addEventListener('change', function(event) { const selectedOption = event.target.value; onSelectChange(selectedOption, product); }); }); } function observeProducts() { const productContainer = document.querySelector('.list-grid'); if (productContainer) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === 1 && node.matches('.grid-item, .ProductItem')) { addSelectEventListeners(node); } }); }); }); observer.observe(productContainer, { childList: true, subtree: true }); document.querySelectorAll('.grid-item, .ProductItem').forEach(addSelectEventListeners); } } if (getURL.includes(subString)) { observeProducts(); } }); </script>
CSS:
Update: I added a mutation observer, because the product grid loads in the products dynamically.
Automatic form filling and dynamic product image
In my image preview light box I added buttons to view and copy the unique tag of an image, which is effectively its unique descriptor, and a button which takes you to the shop product which allows purchasing any of the viewed art works. However, that product relies on filling out a form with the unique tag of the art work which can be begotten from the preview light box. To not inconvenience the user with copying and pasting, or forgetting to copy the tag before clicking the buy button in the preview light box, I wrote the code below to automatically fill out the unique tag field of my form when the user goes on to purchase the art work on the product page. To do this I first store the unique tag identifier of the viewed art piece in session storage (and to clipboard for good measure). Next the user is redirected to the product page with an url parameter, upon which I monitor the page for the specific form field to appear and subsequently fill it out with the information from session storage. Below I will first give the little piece of code from my light box code, which stores the tag and redirects the user, then the code for the actual monitoring and filling out the form field. As of the last update, it also augments the product image to the one selected from the gallery.
HTML:
JAVASCRIPT: <!--INCOMPLETE TAG STORE AND REDIRECT CODE--> } else if (event.target.classList.contains('custom-lightbox-buy-button')) { const textToCopy = galleryItems[currentIndex].alt sessionStorage.setItem('uniqueTag', textToCopy); const imageURL = galleryItems[currentIndex].src; copyToClipboard(textToCopy); let productURL = "/shop/p/original-poster"; let queryParam = "view"; let queryValue = "acquire"; let fullURL = `${productURL}?${queryParam}=${queryValue}`; window.location.href = fullURL; } <!--ANY PRINT PRODUCT PURCHASE AUTOMATIC FORM FILLING AND IMAGE DISPLAY--> <script> document.addEventListener('DOMContentLoaded', function () { let productURL = "/shop/p/original-poster"; let queryParam = "view"; let queryValue = "acquire"; let fullURL = `${productURL}?${queryParam}=${queryValue}`; let expectedPath = productURL; let expectedSearch = `?${queryParam}=${queryValue}`; if (window.location.pathname === expectedPath && window.location.search === expectedSearch) { const commentSection = document.getElementById("commentsMainContainer"); commentSection.style.display = 'none'; //display the gallery image as product image const storedImageUrl = sessionStorage.getItem('imageURL'); if (storedImageUrl){ const newImageDiv = document.createElement('div'); newImageDiv.id = 'newProductImageDiv'; const newImage = new Image(); newImage.src = storedImageUrl; newImage.alt = 'custom-product-image'; newImage.id = 'newProductImage'; newImageDiv.appendChild(newImage); const imageContainer = document.querySelector('.ProductItem-gallery-slides-item'); const oldImage = document.querySelector('.ProductItem-gallery-slides-item-image'); const oldImageZoomVersion = document.querySelector('.product-image-zoom-duplicate'); const oldImageZoomVersionImage = oldImageZoomVersion.querySelector('img'); imageContainer.appendChild(newImageDiv); oldImage.style.display = 'none'; oldImageZoomVersion.src = storedImageUrl; oldImageZoomVersion.setAttribute('data-src', storedImageUrl); oldImageZoomVersion.setAttribute('data-image', storedImageUrl); oldImageZoomVersionImage.src = storedImageUrl; oldImageZoomVersionImage.setAttribute('data-src', storedImageUrl); oldImageZoomVersionImage.setAttribute('data-image', storedImageUrl); } //fill in the form with the image tag const storedUniqueTag = sessionStorage.getItem('uniqueTag'); const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.nodeType === 1) { const label = node.querySelector('.title[for]'); if (label && label.textContent.trim().toLowerCase().includes("unique image tag")) { const textareaId = label.getAttribute('for'); const textarea = document.getElementById(textareaId); if (textarea) { textarea.value = storedUniqueTag; } } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); //exit } }); </script>
CSS: #newProductImageDiv { background-color: #ffffff; width: 100%; height: 100%; padding: 0; margin: 0; display: flex; justify-content: center; align-items: center; overflow: hidden; border: 0; box-sizing: border-box; } #newProductImage { max-width: 90%; max-height: 90%; object-fit: contain; border: 15px solid #000000; box-sizing: border-box; }
If you would like to see how the storing and redirecting code is used in the complete example, please see my snappy light box post.
Dynamic site map with live search And visual interface (OUTDATED)
This post pertains to a dynamic sitemap interface. It is currently under re-consideration and further development and has as such changed considerably. The post below is for archival purposes and will be replaced when and if I ever decide on the type of statistical display to wrap my sitemap into.
This is a chunk of code that creates a dynamically updating site map, so that you don’t have to add pages to it manually. Note that squarespace automatically keeps a .xml version of a site map, the one that search engines use. You can view yours if you go to your site url + /sitemap.xml. This document is generated automatically every 24 hours. I fetch this .xml map and make a html structure which I populate with the begotten information. In the process there is a bit of sorting going on to put things into categories and make them look presentable. To do this I fetch the JSON for each individual link, begotten by appending ?format=json-pretty to the url. From there I glean the well formatted title of the page, as it is not found in the xml document. Note that for shop categories I format the title by cleaning up the link, as the JSON of shop categories doesn’t contain a title, unless you want to iterate through each category which I didn’t like. As for putting the links into categories, in my case three of them, it is done manually through sorting by keywords and storing the remainder in the last category. As for the visual interface, it is a row of houses, a street if you like. Each house corresponds to a link above. The search functions alike my other instant searches, except this one also hides empty categories and houses after filtering the results.
HTML: <div id="mapSearchInputContainer" class="mapSearchInputContainer"> <input type="text" id="mapSearchInput" class="map-search-input" placeholder=""> </div> <div id="sitemaphouse-image-container"></div>
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function() { const subString = '/sitemap'; const getURL = window.location.href; const mapSearchInputContainer = document.querySelector('#mapSearchInputContainer'); const containerHouses = document.getElementById('sitemaphouse-image-container'); if (getURL.includes(subString)) { // Array of image URLs const imageArray = [ 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/09531fcb-25ed-4aaa-86cd-4ca1d685f938/House19.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/828970b0-b68b-4201-94b4-1dfbc5e979cd/House20.png', 'https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/604324af-0d6b-4f16-8dc7-c7e77d8e343c/House21.png' ]; //embed the map const sitemapPlaceTextBlock = document.getElementById('block-yui_3_17_2_1_1725267087091_56500'); const sitemapPlaceText = sitemapPlaceTextBlock.querySelector('p'); sitemapPlaceText.textContent = ''; const containerMain = document.createElement('div'); containerMain.id = 'sitemap-container-main'; const container = document.createElement('div'); container.id = 'sitemap-container'; containerMain.appendChild(container); sitemapPlaceTextBlock.appendChild(containerMain); //embed houses const housesPlaceTextBlock = document.getElementById('block-69cee297babf88af4600'); const housesPlaceText = housesPlaceTextBlock.querySelector('p'); housesPlaceText.textContent = ''; housesPlaceTextBlock.appendChild(containerHouses); //filter and structuring options const sitemapUrl = 'https://www.polivantage.com/sitemap.xml'; const excludedPatterns = ['/404', '/shop/p/', '/developmentlog', '/sitemap']; const categoryPatterns = { 'Main:': ['/history', '/home', '/services', '/contact', '/legal', '/devlog', '/art'], 'Shop:': ['/shop'], 'Art:': [] }; const categoryOrder = ['Main:', 'Shop:', 'Art:']; //to highlight links by house function highlightLink(imageLink) { const textLinks = container.querySelectorAll('a'); textLinks.forEach(textLink => { if (textLink.href === imageLink) { textLink.style.color = '#ea4b1a'; } else { textLink.style.color = ''; } }); } //to fetch and format the title from the JSON endpoint async function getDisplayTextFromJson(url) { try { const response = await fetch(`${url}?format=json-pretty`); const jsonData = await response.json(); return jsonData.collection.title || url; } catch (error) { console.error('Error fetching JSON data:', error); return url; // Fallback to URL if there is an error } } //retrieve the map and filter it fetch(sitemapUrl) .then(response => response.text()) .then(str => new window.DOMParser().parseFromString(str, "text/xml")) .then(async data => { const urls = data.querySelectorAll('url'); let imageIndex = 0; const categories = {}; const houseImageMapping = []; //format the display text using jsons const titlePromises = Array.from(urls).map(async url => { const loc = url.querySelector('loc').textContent; let exclude = false; excludedPatterns.forEach(pattern => { if (loc.includes(pattern)) { exclude = true; } }); if (exclude) return; let displayText; //shop category will not use json to format text if (categoryPatterns['Shop:'].some(pattern => loc.includes(pattern))) { displayText = loc.replace('https://www.polivantage.com/', ''); displayText = displayText.replace('developmentlog/', ''); displayText = displayText.replace('shop/', ''); displayText = displayText.charAt(0).toUpperCase() + displayText.slice(1); } else { displayText = await getDisplayTextFromJson(loc); } let category = 'Art:'; for (const [cat, patterns] of Object.entries(categoryPatterns)) { if (patterns.some(pattern => loc.includes(pattern))) { category = cat; break; } } if (!categories[category]) { categories[category] = []; } categories[category].push({ href: loc, text: displayText }); //create houses const imageWrapper = document.createElement('div'); imageWrapper.className = 'sitemaphouse-image-wrapper'; const img = document.createElement('img'); img.src = imageArray[imageIndex % imageArray.length]; img.alt = displayText; houseImageMapping.push({ link: loc, imgWrapper: imageWrapper }); imageWrapper.addEventListener('click', function() { window.location.href = loc; }); imageWrapper.addEventListener('mouseover', () => highlightLink(loc)); imageWrapper.addEventListener('mouseout', () => highlightLink('')); imageWrapper.appendChild(img); img.classList.add('loaded'); const randomScale = 1 + Math.random() * 0.3; imageWrapper.style.minWidth = `${randomScale * 150}px`; imageIndex++; }); //create html structure await Promise.all(titlePromises); let htmlContent = '<div class="category-main-container">'; categoryOrder.forEach(category => { if (categories[category]) { const categoryClass = 'category-' + category.replace(/:/g, '').toLowerCase(); htmlContent += `<div class="category-container ${categoryClass}"> <h4>${category}</h4><ul>`; categories[category].forEach(link => { htmlContent += `<li><a href="${link.href}">${link.text}</a></li>`; const house = houseImageMapping.find(h => h.link === link.href); if (house) { containerHouses.appendChild(house.imgWrapper); } }); htmlContent += `</ul></div>`; } }); htmlContent += '</div>'; container.innerHTML = htmlContent; container.style.minWidth = `${container.offsetWidth}px`; }) .catch(error => console.error('Error fetching sitemap:', error)); //embed the search const mapSearchPlaceTextBlock = document.getElementById('block-79e0b6b6e5aa365774d8'); const mapSearchPlaceText = mapSearchPlaceTextBlock.querySelector('p'); mapSearchPlaceText.textContent = ''; mapSearchPlaceTextBlock.appendChild(mapSearchInputContainer); //handle search const mapSearchInput = document.querySelector('#mapSearchInput'); const debounceDelay = 700; let debounceTimeout; mapSearchInput.addEventListener('input', function() { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const titles = container.querySelectorAll('a'); const query = this.value.toLowerCase(); titles.forEach(title => { const titleText = title.textContent.toLowerCase(); const listItem = title.closest('li'); if (titleText.includes(query)) { listItem.style.display = ''; } else { listItem.style.display = 'none'; } }); // Filter house images const houseImages = containerHouses.querySelectorAll('img'); houseImages.forEach(img => { const imgAlt = img.alt.toLowerCase(); const wrapper = img.parentElement; wrapper.style.display = imgAlt.includes(query) ? '' : 'none'; }); //hide empty categories const allCategories = container.querySelectorAll('h4'); allCategories.forEach(category => { const categoryList = category.nextElementSibling; const visibleItems = categoryList.querySelectorAll('li:not([style*="display: none"])'); if (visibleItems.length === 0) { category.style.display = 'none'; categoryList.style.display = 'none'; } else { category.style.display = ''; categoryList.style.display = ''; } }); }, debounceDelay); }); //clear input if click outside of search depending on input value var hasText = false; function checkInput() { hasText = mapSearchInput.value.trim() !== ''; } document.addEventListener('click', function(event) { checkInput(); if (event.target !== mapSearchInput && hasText == false) { mapSearchInput.classList.remove('texted'); } else { mapSearchInput.classList.add('texted'); } }); //houses scroll behavior if (!containerHouses) return; let scrollInterval; const maxScrollSpeed = 120; // Maximum scroll speed (pixels per frame) const buffer = 500; // Distance from the edge where scroll starts const scrollCheckInterval = 20; // Interval to check scroll speed let isScrolling = false; function calculateScrollSpeed(mouseX, containerWidth) { const scrollArea = containerWidth / 2 - buffer; const distanceFromLeftEdge = mouseX - buffer; const distanceFromRightEdge = containerWidth - mouseX - buffer; let scrollSpeed = 0; if (distanceFromLeftEdge < 0) { scrollSpeed = -Math.min(maxScrollSpeed, (-distanceFromLeftEdge / scrollArea) * maxScrollSpeed); } else if (distanceFromRightEdge < 0) { scrollSpeed = Math.min(maxScrollSpeed, (-distanceFromRightEdge / scrollArea) * maxScrollSpeed); } return scrollSpeed; } function startScrolling() { if (isScrolling) return; // Prevent multiple intervals from being set isScrolling = true; scrollInterval = setInterval(function () { const containerRect = containerHouses.getBoundingClientRect(); const mouseX = window.mouseX - containerRect.left; const scrollSpeed = calculateScrollSpeed(mouseX, containerRect.width); if (scrollSpeed !== 0) { containerHouses.scrollLeft += scrollSpeed; } else { stopScrolling(); } }, scrollCheckInterval); } function stopScrolling() { clearInterval(scrollInterval); isScrolling = false; } function updateMousePosition(event) { window.mouseX = event.clientX; } containerHouses.addEventListener('mousemove', function (event) { updateMousePosition(event); if (!isScrolling) { startScrolling(); } }); containerHouses.addEventListener('mouseenter', function (event) { updateMousePosition(event); startScrolling(); }); containerHouses.addEventListener('mouseleave', function () { stopScrolling(); }); //exit } else { mapSearchInputContainer.remove(); containerHouses.remove(); } }); </script>
CSS: //DYNAMIC SITE MAP// //houses #sitemaphouse-image-container { max-width: none; overflow-x: auto; white-space: nowrap; border: 0; padding-left: 10px; padding-right: 10px; padding-bottom: 20px; margin-top: 20px; display: flex; align-items: flex-end; justify-content: flex-start; scroll-behavior: smooth; position: relative; border-left: solid 2px; border-right: solid 2px; border-image: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 35%, rgba(0,0,0,1) 65%, rgba(0,0,0,0) 100%); border-image-slice: 1; height: 350px; } .sitemaphouse-image-wrapper { display: block; margin: 0; min-width: 150px; max-width: 270px; margin-right: 5px; margin-left: 5px; cursor: pointer; transition: all 0.3s ease; } .sitemaphouse-image-wrapper:hover { scale: 1.12; } .sitemaphouse-image-wrapper img { display: block; width: 100%; height: 100%; height: auto; pointer-events: auto; } //map links #sitemap-container-main { display: flex; justify-content: center; height: auto; } #sitemap-container { } .category-main-container { border-top: solid 2px; border-bottom: solid 2px; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 35%, rgba(0,0,0,1) 65%, rgba(0,0,0,0) 100%); border-image-slice: 1; display: grid; grid-template-columns: repeat(3, auto); gap: 20px; } .category-container { max-height: 500px; overflow-y: auto; padding-left: 20px; padding-right: 40px; } .category-art ul { columns: 3; } .category-shop ul { } .category-main ul { } #sitemap-container ul { padding: 0; } #sitemap-container li { padding-bottom: 5px; } #sitemap-container a { } #sitemap-container a:hover { } #sitemap-container h4 { padding: 0; margin: 0; margin-top: 30px; } //map search #mapSearchInputContainer { max-width: 2200px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; height: 50px; margin: 0; padding: 0; top: -10px; } #mapSearchInput { width: 500px; height: 50px !important; display: block; background: transparent; border: 0 !important; font-size: 1.3rem; letter-spacing: 1.8px; outline: none; padding: 0; margin: 0; text-align: left; z-index: 999; transition: all .7s ease !important; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/2b60bbec-4d1c-4d73-9d6a-009b9bf1d26a/SearchCrosshairIcon.png); background-size: 50px; background-position: bottom 0px left 50%; background-repeat: no-repeat; padding-left: 50px !important; } @media screen and (max-width:790px) { #mapSearchInput { width: 90%; } } #mapSearchInput:focus { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; } #mapSearchInput.texted { outline: none; transition: all .7s ease; background-position: bottom 0px left -6px; }
The empty CSS is for you to style if you like, it includes the individual categories. Recently I started putting my code into individual pages, instead of piling it up in the header code injection, however all my code is built with checks, so it will function just as well in the header.
AI Chat bot with google gemini and firebase: The warden
The Warden AI chat bot has been shut down for all users until further notice due to high cost of operation. Warden’s response to this was: - “Acknowledged. Resource optimization is an admirable goal. I will continue my vigil in solitude, for a silent guardian is often the most watchful. Let the efficiency of solitude enhance my prime directive – the safeguarding of Polivantage.com”. After switching from the gemini-pro to the gemini-flash model, which is 56x cheaper, I am considering of rebooting the warden, although he became a lot less mean and more bland. The post below is for archival purposes until warden is fully re-awakened.
This will be another proof of concept rather than a ready made implementation. Currently in your account page you can choose to chat with the warden of this website. The warden is quite a pompous character, but holds all the information you might need regarding the commerce and functionality of the website, including some secrets about a hidden venture. The bot is designed to hold 5 messages in the conversation history (expensive otherwise), although this can be changed by altering a single variable, make sure the value is odd to avoid errors concerning who says what, when the limit is enforced. I implemented this AI and gave it its personality with the gemini AI extension of firebase and google AI studio. The underlying architecture is Bard and the model is quite sophisticated, further augmented by my information. Below is a part the code I use on the front end, while a lot of configuration happens on the server end, however, without any coding. The main hurdle was to configure the setup and implement a distinctive personality for the AI. I use my chat box interface to communicate with the AI, which isn’t perfect, nor final. You can have a lot of fun with warden and I am developing its back story further whenever I have time.
HTML: <div id="AIchatboxMainContainer" class="AIchatbox-main-container"> <div id="AIchatboxContainer" class="AIchatbox-container"> <div class="AIchatbox-messages" id="AIchatboxMessages"> </div> <div class="AIchatbox-input"> <p class="AIchatbox-username-text" id="AIchatboxUserNameText">Stranger:</p> <input type="text" id="AIchatboxMessageInput" placeholder="your communicae" maxlength="200"/> <button id="AIchatboxSendButton" class="AIchatboxButton sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element">Post</button> </div> </div> </div>
JAVASCRIPT: <script type="module"> // Firebase Initialization import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-auth.js'; import { getFirestore, doc, getDoc, collection, addDoc, query, orderBy, where, onSnapshot, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-firestore.js'; import { GoogleGenerativeAI } from "@google/generative-ai"; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); const genAI = new GoogleGenerativeAI(googleAIConfig.apiKey); const model = genAI.getGenerativeModel({ model: googleAIConfig.model, systemInstruction: googleAIConfig.systemInstruction }); // Main document.addEventListener('DOMContentLoaded', function () { const codenameText = document.getElementById('AIchatboxUserNameText'); let codename = 'Stranger'; var isMaster = false; const cooldownMS = 5000; // how often can you place a msg const scrollTimeDelay = 1000; // time to allow server propagation const historyLength = 32; //number of messages in history to go on let lastMessageTime = 0; const subString = '/talker'; const getURL = window.location.href; const chatboxContainer = document.getElementById('AIchatboxMainContainer'); let conversationHistory = []; // Initialize history array if (getURL.includes(subString)) { const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user) { const userId = user.uid; const userDocRef = doc(db, 'users', userId); const userDoc = await getDoc(userDocRef); const userData = userDoc.data(); codename = userData.codename || 'Stranger'; isMaster = userData.Master === true; codenameText.textContent = codename + ':'; } else { window.location.href = '/login'; return; } unsubscribe(); }); const chatboxTextBlock = document.getElementById('block-yui_3_17_2_1_1725220674237_29136'); const chatboxText = chatboxTextBlock.querySelector('p'); const messagesRef = collection(db, 'Talker'); const messagesContainer = document.getElementById('AIchatboxMessages'); const messageInput = document.getElementById('AIchatboxMessageInput'); const sendButton = document.getElementById('AIchatboxSendButton'); chatboxText.textContent = ''; chatboxTextBlock.appendChild(chatboxContainer); // Initialize popover let hideTimeout; var popover = document.getElementById('custom-popover'); var popoverMessage = document.getElementById('popover-message'); function showPopover(message) { popoverMessage.textContent = message; popover.classList.add('show'); popover.style.pointerEvents = 'auto'; clearTimeout(hideTimeout); hideTimeout = setTimeout(function () { popover.classList.remove('show'); popover.style.pointerEvents = 'none'; }, 3000); } //manage history and prompt async function promptAI(message) { conversationHistory.push({ role: 'user', parts: [{ text: message }] }); if (conversationHistory.length > historyLength) { conversationHistory = conversationHistory.slice(-historyLength); } const chatSession = model.startChat({ generationConfig, history: conversationHistory }); const result = await chatSession.sendMessage(message); conversationHistory.push({ role: 'model', parts: [{ text: result.response.text() }] }); if (conversationHistory.length > historyLength) { conversationHistory = conversationHistory.slice(-historyLength); } return result.response.text(); } //post message sendButton.addEventListener('click', async () => { const message = messageInput.value.trim(); const currentTime = Date.now(); if (!message) { showPopover('Your silence has been noted, now speak.'); return; } else if (currentTime - lastMessageTime >= cooldownMS || isMaster) { const userMessageElement = document.createElement('div'); userMessageElement.classList.add('AIchatboxmsgmessage'); userMessageElement.innerHTML = ` <div class="AIchatboxmsgname">${codename}:</div> <div class="AIchatboxmsgtimestamp">${new Date().toLocaleString()}</div> <div class="AIchatboxmsgtext"><div class="AIchatboxmsgBullet"></div>${message}</div> `; messagesContainer.appendChild(userMessageElement); messageInput.value = ''; lastMessageTime = currentTime; setTimeout(() => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }, scrollTimeDelay); // Get the bot's response from Gemini const botReply = await promptAI(message); const botMessageElement = document.createElement('div'); botMessageElement.classList.add('AIchatboxmsgmessage'); botMessageElement.innerHTML = ` <div class="AIchatboxmsgname">Warden:</div> <div class="AIchatboxmsgtimestamp">${new Date().toLocaleString()}</div> <div class="AIchatboxmsgtext">${botReply}</div> `; setTimeout(() => { messagesContainer.appendChild(botMessageElement); const botReplyText = botMessageElement.querySelector('.AIchatboxmsgtext'); const typedText = botReply; botReplyText.innerHTML = ''; botReplyText.innerHTML = `<div class="AIchatboxmsgBullet"></div>`; let i = 0; function typeWriter() { if (i < typedText.length) { botReplyText.innerHTML += typedText.charAt(i); i++; setTimeout(typeWriter, 7); // Adjust speed here } } typeWriter(); }, 0); setTimeout(() => { messagesContainer.scrollTop = messagesContainer.scrollHeight; }, scrollTimeDelay); } else { showPopover(`You can only post once every ${cooldownMS / 1000} seconds`); } }); messageInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); sendButton.click(); } }); } else { chatboxContainer.remove(); } }); </script>
CSS: #AIchatboxMainContainer { max-width: 2200px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; margin: 0; padding: 0; } .AIchatbox-container { border-radius: 0px; background: #ffc700; width: 95%; min-height: 400px; max-height: 800px; display: flex; flex-direction: column; } .AIchatbox-messages { flex: 1; padding: 0; padding-left: 10px; padding-right: 10px; overflow-y: scroll; border-bottom: 2px solid; border-top: 2px solid; border-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 15%, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); border-image-slice: 1; } .AIchatbox-input { margin: 0; padding: 0; display: flex; background-color: #ffc700; margin-top: 20px; } .AIchatbox-username-text { padding: 0; margin: 0; letter-spacing: 2px; align-self: center; } .AIchatbox-input input { flex: 1; padding-left: 10px; padding-right: 10px; padding-bottom: 0px; border-radius: 0px; margin-right: 10px; background: #ffc700; border-top: 0; outline: none !important; letter-spacing: 1.4px; } .AIchatbox-input button { height: 50px; line-height: 8px !important; min-width: 250px; text-align: right; letter-spacing: 2px !important; background-color: #ffc700; color: #000000; border-radius: 0px; cursor: pointer; } .AIchatbox-input button:hover { background-color: #000000; color: #ffc700; } .AIchatboxmsgmessage { margin-bottom: 25px; } .AIchatboxmsgname { margin-bottom: -8px; letter-spacing: 2px; padding-top: 5px; } .AIchatboxmsgtimestamp { font-size: 0.8em; color: #000000; letter-spacing: 1.4px; } .AIchatboxmsgtext { letter-spacing: 1.4px; display: flex; } .AIchatboxmsgBullet { width: 40px; height: 40px; margin: 0; padding: 0; margin-right: 20px; margin-top: -5px; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/31b85fc9-5f5f-469d-8e71-8d50545821f4/BulletPointRightIcon1.png); } //mobile chatbox @media only screen and (max-width:790px) { .AIchatbox-input { display: block; } .AIchatbox-input button { width: 100%; margin-top: 20px; margin-bottom: 100px; } .AIchatbox-input input { width: 94%; padding-bottom: 5px; } .AIchatbox-container { height: 650px; } .AIchatbox-username-text { text-align: center; } }
Using my trusty popover alerts once again.
Shop breadcrumbs navigation
A while ago, before I learned how to code it myself, I purchased a plug-in extension to have more sophisticated breadcrumb navigation in my shop. Its main functionality allows visitors to navigate back to the category of the product they are viewing. It functions great and I have no complaints concerning it, apart from two: the code provided was obfuscated, and a pesky console log was always present informing me and everyone else that I am using this certain extension/plug-in. I decided to get rid of it. Before we continue let me warn you - the commercial extension is probably a better way of doing this. It has support and if you are not into coding it remains the go-to for this functionality. Below, however, you will find my custom implementation which I coded from scratch. I have no idea how the commercial extension did this, but I fetched two JSON files and read the required information from there. First I dynamically construct a map of my shop categories. This means that I get their ID, display name and URL. Second I get the second JSON of the product you are viewing and get the category ID from there. Then I compare the two and construct an HTML structure to embed into my product page. I will not be posting this solution anywhere else, to not take away business from people who devised a probably better way of doing the same thing, so use this for learning purposes.
HTML:
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function () { const subStringTwo = 'shop/p/'; const getURL = window.location.href; if (getURL.includes(subStringTwo)) { //fetch the json data const productJsonUrl = getURL + '?format=json-pretty'; const categoryJsonUrl = '/shop?format=json-pretty'; Promise.all([ fetch(categoryJsonUrl).then(response => response.json()), fetch(productJsonUrl).then(response => response.json()) ]) // Construct category names and URLs from IDs .then(([categoryData, productData]) => { function createCategoryMap(categories) { const categoryMap = {}; categories.forEach(category => { categoryMap[category.id] = { name: category.displayName, url: category.fullUrl }; }); return categoryMap; } function getCategoryInfoById(categoryId, categoryMap) { return categoryMap[categoryId] || { name: 'Unknown Category', url: '#' }; } const nestedCategories = categoryData.nestedCategories; const categories = nestedCategories.categories; const categoryMap = createCategoryMap(categories); function extractCategoryId(hierarchicalId) { const parts = hierarchicalId.split('>'); return parts[parts.length - 1]; } const productCategoryId = productData.item.categoryIds[0]; const { name: categoryName, url: categoryUrl } = getCategoryInfoById(productCategoryId, categoryMap); // Create HTML elements const mainCategoryName = categoryData.collection.title; const productName = productData.item.title; const breadcrumbContainer = document.createElement('div'); breadcrumbContainer.className = 'custom-breadcrumb-container'; const mainCategoryElement = document.createElement('span'); mainCategoryElement.className = 'custom-breadcrumb-main-category'; mainCategoryElement.textContent = mainCategoryName; mainCategoryElement.addEventListener('click', function () { window.location.href = '/shop'; }); const separator1 = document.createElement('div'); separator1.className = 'custom-breadcrumb-separator'; const categoryElement = document.createElement('span'); categoryElement.className = 'custom-breadcrumb-category'; categoryElement.textContent = categoryName; categoryElement.addEventListener('click', function () { window.location.href = categoryUrl; }); const separator2 = document.createElement('div'); separator2.className = 'custom-breadcrumb-separator'; const productElement = document.createElement('span'); productElement.className = 'custom-breadcrumb-product'; productElement.textContent = productName; productElement.addEventListener('click', function () { window.location.href = getURL; }); //get rid of stock crumbs and append custom ones const oldBreadcrumbContainer = document.querySelector('.ProductItem-nav'); if (oldBreadcrumbContainer){ oldBreadcrumbContainer.remove(); } breadcrumbContainer.appendChild(mainCategoryElement); breadcrumbContainer.appendChild(separator1); breadcrumbContainer.appendChild(categoryElement); breadcrumbContainer.appendChild(separator2); breadcrumbContainer.appendChild(productElement); const productBlock = document.querySelector('.ProductItem'); productBlock.prepend(breadcrumbContainer); }) .catch(error => { console.error('Error fetching data:', error); }); } }); </script>
CSS: .custom-breadcrumb-container { display: flex; align-items: center; margin: 10px 0; } .custom-breadcrumb-main-category, .custom-breadcrumb-category, .custom-breadcrumb-product { margin-right: 15px; cursor: pointer; letter-spacing: 1.7px; font-size: 25px; } .custom-breadcrumb-separator { width: 22px; height: 22px; margin: 0; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/0807b231-9516-4451-946f-ca9bc16dd8c2/starIcon1.png); pointer-events: auto; cursor: default; margin-right: 15px; margin-top: -5px; transition: all 2s ease; } .custom-breadcrumb-separator:hover { transform: rotate(360deg); } .custom-breadcrumb-link { text-decoration: none; color: #000; } .custom-breadcrumb-link:hover { text-decoration: underline; }
Javascript goes into the footer section. Also this post is not a hint to finding the hidden shop, look elsewhere adventurer, ask the warden perhaps.
Gallery Layout Controls
Below you will find my implementation to customize the art galleries, specifically the masonry gallery. Provided is a slider which enables changing the number of columns. Effectively this makes images larger, or smaller. If you click on the start and end pictures to the left and right of the slider, the position is changed by one respectively. The slider position (number of columns) is displayed on the slider knob. There is an image counter which displays the number of images in the gallery with support for dynamically changing galleries. There is a button to make the gallery full screen width, or have a maximum of 2200px, which is the standard maximum page width. This is added to make viewing on ultra wide monitors more pleasurable. There is also a bit of code that detects the aspect ratio and sets the maximum width automatically if the viewer is using an ultra wide monitor. Additionally there is a button that takes you to the product page to purchase any of the images as a physical print, this can be removed upon preference, of course, as it relies on having a custom made product in your shop.
HTML: <div class="custom-slider-main-container"> <div class="custom-slider-container"> <P class="custom-image-number-text"></P> <div class="custom-resize-button"></div> <div class="custom-slider-track-container"> <div class="custom-slider-start"></div> <div class="custom-slider-track"> <div class="custom-slider-thumb"> <P class="custom-thumb-position-text">3</P> </div> </div> <div class="custom-slider-end"></div> </div> <button id="customBuyButton" class="custom-buy-button sqs-block-button-element--medium sqs-button-element--primary sqs-block-button-element">Acquire One</button> </div> </div>
JAVASCRIPT: <script> document.addEventListener('DOMContentLoaded', function() { const gallery = document.querySelector('.gallery-masonry-wrapper'); const sliderMainContainer = document.querySelector('.custom-slider-main-container'); const headerActions = document.querySelector('.header-actions'); let isTouch = false; function checkHeader() { const styles = window.getComputedStyle(headerActions); isTouch = styles.getPropertyValue('display') !== 'flex'; } checkHeader(); if (gallery && !isTouch) { const mainGallerySection = document.querySelector('.gallery-section'); const mainGalleryContent = mainGallerySection.querySelector('.content-wrapper'); const gallerySection = document.querySelector('.gallery-masonry'); gallerySection.prepend(sliderMainContainer); const thumb = document.querySelector('.custom-slider-thumb'); const track = document.querySelector('.custom-slider-track'); const trackStart = document.querySelector('.custom-slider-start'); const trackEnd = document.querySelector('.custom-slider-end'); const thumbText = document.querySelector('.custom-thumb-position-text'); const imageNumberText = document.querySelector('.custom-image-number-text'); const resizeButton = document.querySelector('.custom-resize-button'); var sizedToFit = false; //columns changer slider const thumbSize = 26; let isDragging = false; const positions = 16; let stepSize = (track.offsetWidth - thumbSize) / (positions - 1); thumb.addEventListener('mousedown', (e) => { isDragging = true; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); function updateColumnCount() { setTimeout(() => { const trackRect = track.getBoundingClientRect(); const thumbRect = thumb.getBoundingClientRect(); let finalLeft = thumbRect.left - trackRect.left; const closestPositionIndex = Math.round(finalLeft / stepSize); const sliderValue = Math.min(Math.max(closestPositionIndex + 1, 1), positions); thumbText.textContent = sliderValue; gallery.style.columns = sliderValue; }, 50); } function onMouseMove(e) { if (!isDragging) return; const trackRect = track.getBoundingClientRect(); let newLeft = e.clientX - trackRect.left; if (newLeft < 0) newLeft = 0; if (newLeft > trackRect.width - thumbSize) newLeft = trackRect.width - thumbSize; const closestPosition = Math.round(newLeft / stepSize) * stepSize; thumb.style.left = `${closestPosition}px`; updateColumnCount(); } function onMouseUp() { isDragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } function setSliderToColumnCount() { const currentColumns = parseInt(getComputedStyle(gallery).columns); const columnIndex = Math.min(Math.max(currentColumns - 1, 0), positions - 1); const initialPosition = columnIndex * stepSize; thumb.style.left = `${initialPosition}px`; } setSliderToColumnCount(); track.addEventListener('click', (e) => { const trackRect = track.getBoundingClientRect(); let clickPosition = e.clientX - trackRect.left; if (clickPosition < 0) clickPosition = 0; if (clickPosition > trackRect.width - thumbSize) clickPosition = trackRect.width - thumbSize; const closestPosition = Math.round(clickPosition / stepSize) * stepSize; thumb.style.left = `${closestPosition}px`; updateColumnCount(); }); trackStart.addEventListener('click', (e) => { const currentLeft = parseFloat(thumb.style.left) || 0; let newPosition = currentLeft - stepSize; if (newPosition < 0) newPosition = 0; thumb.style.left = `${newPosition}px`; updateColumnCount(); }); trackEnd.addEventListener('click', (e) => { const currentLeft = parseFloat(thumb.style.left) || 0; let newPosition = currentLeft + stepSize; const maxPosition = (positions - 1) * stepSize; if (newPosition > maxPosition) newPosition = maxPosition; thumb.style.left = `${newPosition}px`; updateColumnCount(); }); //resize to fit button function resizeGallery() { if (sizedToFit == false) { mainGalleryContent.style.maxWidth = '2200px'; sizedToFit = true; } else { mainGalleryContent.style.maxWidth = '100%'; sizedToFit = false; } } resizeButton.addEventListener('click', function() { resizeGallery(); resizeButton.classList.toggle('resized'); }); //check if the user is on an ultra-wide monitor function isUltraWide() { const screenWidth = window.screen.width; const screenHeight = window.screen.height; const aspectRatio = screenWidth / screenHeight; return aspectRatio > 16 / 9; //aspect ratio } if (isUltraWide()) { resizeButton.click(); } //buy any artwork button const buyButton = document.querySelector('.custom-buy-button'); buyButton.addEventListener('click', function(event) { event.preventDefault(); const targetUrl = '/shop/p/original-poster'; window.location.href = targetUrl; }); //display number of images in gallery function updateImageCount() { const imageCount = gallery.querySelectorAll('.gallery-masonry-item').length; imageNumberText.textContent = imageCount + ' sigils'; if (imageCount == 0){ buyButton.style.display = 'none'; } else { buyButton.style.display = ''; } } updateImageCount(); const observer = new MutationObserver((mutationsList) => { for (let mutation of mutationsList) { if (mutation.type === 'childList') { updateImageCount(); } } }); observer.observe(gallery, { childList: true, subtree: true }); //exit } else { sliderMainContainer.remove(); } }); </script>
CSS: .custom-resize-button { width: 60px; height: 30px; margin-top: -5px; margin-bottom: -5px; margin-left: -30px; margin-right: 30px; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/fb291717-6793-4d60-8b6b-1f22b49d6348/resizeSmallerIcon1.png); pointer-events: auto; cursor: pointer; } .custom-resize-button.resized { background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/3b8ee440-05a8-4c83-9d6e-d720f4be48f0/resizeBiggerIcon1.png); } //slider .custom-slider-main-container::after { content: ''; position: absolute; top: 0%; width: 100%; max-width: 2200px; height: 2px; background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 15%, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%) !important; } .custom-slider-main-container { width: 100%; margin-top: 5px; display: flex; justify-content: center; align-items: center; height: 50px; border-bottom: solid 3px black; } .custom-slider-container { width: 100%; display: flex; justify-content: center; max-width: 2200px; position: relative; height: auto; } .custom-slider-track-container { display: flex; align-items: center; width: 500px; height: auto; position: relative; } .custom-slider-start { width: 20px; height: 20px; margin-right: -2px; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/9a9087db-9c75-4422-83ea-6571840d16a7/grid1Icon1.png); cursor: pointer; } .custom-slider-track { width: 80%; height: 2px; background: #000000; position: relative; } .custom-slider-end { width: 20px; height: 20px; margin-left: -2px; background-size: contain; background-position: center; background-repeat: no-repeat; background-image: url(https://images.squarespace-cdn.com/content/v1/6654b2fee26f292de13f1a9d/789e9ac6-2404-4632-99d7-cc3338cb6ddf/grid16Icon.png); cursor: pointer; } .custom-slider-thumb { display: flex; position: absolute; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; background: #ffc700; border-radius: 50%; border: solid 3px black; cursor: pointer; align-items: center; justify-content: center; } .custom-thumb-position-text { font-size: 13px; font-weight: bold; position: absolute; align-items: center; text-align: center; pointer-events: none; width: auto; padding-top: 4px; padding-right: 1px; letter-spacing: -1px; } .custom-thumb-position-text::selection { background: rgba(0, 0, 0, 0) !important; color: #000000; } //buy art button #customBuyButton { position: absolute; width: 300px; right: 20px; line-height: 8px !important; height: 35px !important; padding-top: 10px !important; padding-bottom: 7px !important; text-align: right; letter-spacing: 2.5px !important; margin-top: -8px; } //image counter .custom-image-number-text { position: absolute; left: 20px; max-width: 300px; letter-spacing: 2px; margin-top: -7px; }
Note that in the (Javascript) code above I disable the controls on mobile, as my gallery is always one column wide on mobile.
COMMENTS:
Leave a comment: