Development Log

Lev Lev

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);
}
Read More
Lev Lev

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;
}
Read More
Lev Lev

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; 
}
Read More
Lev Lev

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%);
}
Read More
Lev Lev

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.

Read More
Lev Lev

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');
}
Read More
Lev Lev

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;
  }
}
Read More
Lev Lev

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}&current_weather=true&daily=sunrise,sunset`;” is the correct api call constant. My automatic highlighting distorts this link in the code above.

Read More
Lev Lev

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;
  } 
}
Read More
Lev Lev

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;
}
Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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;
}
Read More
Lev Lev

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;
}
Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More
Lev Lev

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.

Read More