← back to Damask Kaleidoscope

gemma3-damask-kaleidoscope.html

360 lines

<!DOCTYPE html>
<!-- Designer Wallcoverings — Damask Kaleidoscope · v1.0.1 -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Designer Wallcoverings — Damask Kaleidoscope</title>
<style>
  :root { color-scheme: dark; }
  html, body { margin: 0; height: 100%; overflow: hidden; background: #060505; }
  canvas { display: block; position: fixed; inset: 0; cursor: crosshair; touch-action: none; }

  #ui {
    position: fixed; inset: 0; pointer-events: none;
    font-family: "Optima", "Palatino Linotype", "Cormorant Garamond", Georgia, serif;
    color: #f3e7cf; z-index: 10;
  }
  #title {
    position: absolute; top: 5.2vh; left: 0; right: 0; text-align: center;
    letter-spacing: .42em; text-transform: uppercase;
    font-size: clamp(11px, 1.7vw, 17px); font-weight: 500;
    color: rgba(243,231,207,.62); text-shadow: 0 1px 22px rgba(0,0,0,.85);
    transition: opacity 1.2s ease;
  }
  #title small { display:block; letter-spacing:.24em; font-size:.62em; margin-top:.7em; color:rgba(243,231,207,.34); }

  #palette {
    position: absolute; bottom: 6.4vh; left: 0; right: 0; text-align: center;
    letter-spacing: .34em; text-transform: uppercase;
    font-size: clamp(10px, 1.35vw, 14px);
    color: rgba(243,231,207,.55);
    opacity: 0; transition: opacity .5s ease; text-shadow: 0 1px 18px rgba(0,0,0,.8);
  }
  #palette b { color: var(--accent, #e9c877); font-weight: 600; }

  #hint {
    position: absolute; bottom: 3.0vh; left: 0; right: 0; text-align: center;
    letter-spacing: .26em; text-transform: uppercase;
    font-size: clamp(8.5px, 1.05vw, 11px); color: rgba(243,231,207,.28);
    transition: opacity 1.6s ease;
  }
  .faded { opacity: 0 !important; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
  <div id="title">Damask Kaleidoscope<small>Designer Wallcoverings</small></div>
  <div id="palette">Palette · <b id="palName">Byzantine Gold</b></div>
  <div id="hint">move to warp the symmetry · click to change palette</div>
</div>

<script>
"use strict";
(() => {
  const canvas = document.getElementById("c");
  const ctx = canvas.getContext("2d", { alpha: false });

  // Offscreen "tube contents" — the evolving damask that the kaleidoscope mirrors.
  const src = document.createElement("canvas");
  const sctx = src.getContext("2d");

  /* ---------- palettes (luxury gold / charcoal / blush families) ---------- */
  const PALETTES = [
    { name: "Byzantine Gold", deep:"#0c0a07", bg:"#1c150d", gold:"#d8b45a", blush:"#e5a79c", accent:"#f6e7c2" },
    { name: "Charcoal & Blush", deep:"#0b0a0c", bg:"#221d22", gold:"#c9a24b", blush:"#dc8b98", accent:"#f3d9c5" },
    { name: "Emerald Court", deep:"#050f0a", bg:"#0f1c15", gold:"#cfab61", blush:"#84c6a6", accent:"#eef2d6" },
    { name: "Amethyst Royale", deep:"#0a0710", bg:"#1a1122", gold:"#caa64f", blush:"#c691d2", accent:"#f0e1f6" },
  ];

  let palIndex = 0;
  let curPal = mixPalette(PALETTES[0], PALETTES[0], 0);
  let fromPal = PALETTES[0], toPal = PALETTES[0], palT = 1; // crossfade progress

  /* ---------- geometry / device ---------- */
  let W = 0, H = 0, cx = 0, cy = 0, R = 0, dpr = 1;
  const SIDES = 12;                 // kaleidoscope mirror segments (even -> seamless)
  const SLICE = (Math.PI * 2) / SIDES;
  let S = 0;                        // source (offscreen) size in px

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth; H = window.innerHeight;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    cx = W / 2; cy = H / 2;
    R = Math.hypot(W, H) / 2 + 4;   // cover the whole screen
    // source square, capped for performance; center at S/2
    S = Math.min(Math.round(R * 1.15), 900);
    src.width = S; src.height = S;
  }
  window.addEventListener("resize", resize);
  resize();

  /* ---------- pointer warp ---------- */
  let mx = 0, my = 0;               // normalized -1..1 from center (target)
  let wx = 0, wy = 0;               // smoothed
  let warp = 0, warpTarget = 0;     // 0..1 distance-driven warp strength
  function pointer(px, py) {
    mx = (px - cx) / (W / 2);
    my = (py - cy) / (H / 2);
    warpTarget = Math.min(Math.hypot(mx, my), 1.4);
    fade(hint);
  }
  canvas.addEventListener("mousemove", e => pointer(e.clientX, e.clientY));
  canvas.addEventListener("touchmove", e => {
    if (e.touches[0]) pointer(e.touches[0].clientX, e.touches[0].clientY);
    e.preventDefault();
  }, { passive: false });

  /* ---------- palette cycling ---------- */
  function cyclePalette() {
    fromPal = curPal;
    palIndex = (palIndex + 1) % PALETTES.length;
    toPal = PALETTES[palIndex];
    palT = 0;
    const el = document.getElementById("palName");
    el.textContent = toPal.name;
    const p = document.getElementById("palette");
    document.getElementById("ui").style.setProperty("--accent", toPal.accent);
    p.style.opacity = "1";
    clearTimeout(cyclePalette._t);
    cyclePalette._t = setTimeout(() => p.style.opacity = "0", 2200);
    fade(document.getElementById("title"));
  }
  canvas.addEventListener("click", cyclePalette);
  canvas.addEventListener("touchend", e => { cyclePalette(); e.preventDefault(); });

  const hint = document.getElementById("hint");
  function fade(el){ if(el && !el.classList.contains("faded")) el.classList.add("faded"); }

  /* ---------- color helpers ---------- */
  function hexToRgb(h){ const n = parseInt(h.slice(1),16); return [n>>16&255, n>>8&255, n&255]; }
  function lerp(a,b,t){ return a+(b-a)*t; }
  function mixHex(h1,h2,t){
    const a=hexToRgb(h1), b=hexToRgb(h2);
    return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;
  }
  function mixPalette(a,b,t){
    return { name:b.name, deep:mixHex(a.deep,b.deep,t), bg:mixHex(a.bg,b.bg,t),
             gold:mixHex(a.gold,b.gold,t), blush:mixHex(a.blush,b.blush,t), accent:mixHex(a.accent,b.accent,t) };
  }
  // parse an "rgb(...)" string back to [r,g,b] so we can build rgba() glow strings from mixed colors
  function toRgb(str){ const m=str.match(/\d+/g); return m?[+m[0],+m[1],+m[2]]:[0,0,0]; }
  function rgbaOf(str,al){ const a=toRgb(str); return `rgba(${a[0]},${a[1]},${a[2]},${al})`; }

  /* ---------- the evolving damask motif (drawn into the source canvas) ----------
     Rendered with 6-fold rotational symmetry so it already reads as ornament;
     the 12-way kaleidoscope then mirrors it into a seamless mandala.            */
  function drawSource(t, pal, swirl, amp) {
    const g = sctx, rs = S / 2;
    // background: deep radial well
    const bgGrad = g.createRadialGradient(rs, rs, rs*0.02, rs, rs, rs);
    bgGrad.addColorStop(0, pal.bg);
    bgGrad.addColorStop(0.55, pal.bg);
    bgGrad.addColorStop(1, pal.deep);
    g.setTransform(1,0,0,1,0,0);
    g.fillStyle = bgGrad;
    g.fillRect(0,0,S,S);

    g.translate(rs, rs);
    g.globalCompositeOperation = "lighter";

    const ARMS = 6;
    for (let k=0; k<ARMS; k++) {
      g.save();
      g.rotate((k/ARMS)*Math.PI*2 + swirl*0.35);
      drawFlourish(g, rs, t, pal, amp, k);
      g.restore();
    }

    // central medallion bloom
    const mr = rs * (0.16 + 0.03*Math.sin(t*0.9));
    const med = g.createRadialGradient(0,0,0,0,0,mr);
    med.addColorStop(0, rgbaOf(pal.accent, 0.9));
    med.addColorStop(0.4, rgbaOf(pal.gold, 0.55));
    med.addColorStop(1, rgbaOf(pal.gold, 0));
    g.fillStyle = med;
    g.beginPath(); g.arc(0,0,mr,0,Math.PI*2); g.fill();

    g.globalCompositeOperation = "source-over";
    g.setTransform(1,0,0,1,0,0);
  }

  // one ornamental "arm": a baseline ogee (S-curve) vine + counter-scroll, strung
  // with damask leaves, buds and gilt dots. Curvy even at rest so it always reads
  // as ornament, not spokes; morphs & opens up with time + pointer warp (amp).
  function drawFlourish(g, rs, t, pal, amp, seed) {
    const ph = seed * 1.7;
    const sway = Math.sin(t*0.8 + ph) * (0.18 + amp*0.5);
    const breathe = 0.9 + 0.1*Math.sin(t*1.1 + ph);

    // Baseline ogee: an intrinsic S-curl that sweeps out and hooks back — the
    // signature damask ogee — independent of sway so it's ornate with no input.
    const p0 = { x: 0, y: 0 };
    const p1 = { x: rs*(0.34 + 0.06*sway),  y: -rs*0.10 };            // curl out low
    const p2 = { x: rs*(0.20 - 0.10*sway),  y: -rs*(0.52*breathe) };  // sweep up & back in
    const p3 = { x: rs*(0.60 + 0.08*Math.sin(t*0.7+ph)),
                 y: -rs*(0.90*breathe) };                             // hook to the tip

    // gilt stem: soft outer glow pass, then a bright core pass
    g.lineCap = "round"; g.lineJoin = "round";
    for (let pass=0; pass<2; pass++) {
      g.beginPath();
      g.moveTo(p0.x,p0.y);
      g.bezierCurveTo(p1.x,p1.y,p2.x,p2.y,p3.x,p3.y);
      if (pass===0){ g.strokeStyle = rgbaOf(pal.gold,0.14); g.lineWidth = rs*0.060; }
      else         { g.strokeStyle = rgbaOf(pal.gold,0.62); g.lineWidth = rs*0.020; }
      g.stroke();
    }

    // counter-scroll: a smaller curl branching the other way for density
    const c0 = bez(p0,p1,p2,p3,0.42);
    const c1 = { x: c0.x - rs*(0.22+0.05*sway), y: c0.y - rs*0.06 };
    const c2 = { x: c0.x - rs*0.10,             y: c0.y - rs*0.30*breathe };
    const c3 = { x: c0.x + rs*0.06,             y: c0.y - rs*0.40*breathe };
    g.beginPath();
    g.moveTo(c0.x,c0.y);
    g.bezierCurveTo(c1.x,c1.y,c2.x,c2.y,c3.x,c3.y);
    g.strokeStyle = rgbaOf(pal.gold,0.4); g.lineWidth = rs*0.013; g.stroke();

    // leaves / buds strung along the main stem
    const LEAVES = 5;
    for (let i=1;i<=LEAVES;i++){
      const u = i/(LEAVES+0.5);
      const b = bez(p0,p1,p2,p3,u);
      const tang = bezTan(p0,p1,p2,p3,u);
      const ang = Math.atan2(tang.y, tang.x);
      const side = (i%2===0)?1:-1;
      const scale = rs*(0.20*(1-u*0.4)) * (0.9+0.25*Math.sin(t*1.3+ph+i));
      drawLeaf(g, b.x, b.y, ang + side*(1.2+0.2*Math.sin(t+i)), scale, side, pal, amp);
    }
    // a couple of leaves on the counter-scroll tip
    drawLeaf(g, c3.x, c3.y, Math.atan2(c3.y-c2.y, c3.x-c2.x)-0.6, rs*0.14*breathe, -1, pal, amp);

    // blossom teardrop at the tip
    drawLeaf(g, p3.x, p3.y, Math.atan2(p3.y,p3.x), rs*0.15*(0.9+0.2*Math.sin(t*1.6+ph)), 1, pal, amp, true);

    // gilt punctuation dots along the stem
    for (let i=0;i<4;i++){
      const u = 0.18 + i*0.2;
      const b = bez(p0,p1,p2,p3,u);
      const dr = rs*0.013*(1.4+Math.sin(t*2+i+ph));
      g.beginPath();
      g.fillStyle = rgbaOf(pal.accent, 0.55);
      g.arc(b.x,b.y,Math.max(dr,0.5),0,Math.PI*2); g.fill();
    }
  }

  // a filled damask leaf/petal (teardrop) with blush body + gilt rib
  function drawLeaf(g, x, y, ang, size, side, pal, amp, bloom) {
    g.save();
    g.translate(x,y); g.rotate(ang);
    const w = size*(bloom?0.85:0.55), l = size*(bloom?1.15:1.6);
    g.beginPath();
    g.moveTo(0,0);
    g.quadraticCurveTo(w*side, -l*0.5, 0, -l);
    g.quadraticCurveTo(-w*side*0.62, -l*0.5, 0, 0);
    const lg = g.createLinearGradient(0,0,0,-l);
    lg.addColorStop(0, rgbaOf(pal.blush, 0.34+amp*0.12));
    lg.addColorStop(0.6, rgbaOf(pal.blush, 0.16));
    lg.addColorStop(1, rgbaOf(pal.accent, 0.05));
    g.fillStyle = lg; g.fill();
    // gilt rib
    g.beginPath(); g.moveTo(0,0); g.lineTo(0,-l*0.92);
    g.strokeStyle = rgbaOf(pal.gold, 0.4); g.lineWidth = Math.max(size*0.03,0.4); g.stroke();
    g.restore();
  }

  // cubic bezier point + tangent
  function bez(p0,p1,p2,p3,u){ const m=1-u;
    return { x: m*m*m*p0.x+3*m*m*u*p1.x+3*m*u*u*p2.x+u*u*u*p3.x,
             y: m*m*m*p0.y+3*m*m*u*p1.y+3*m*u*u*p2.y+u*u*u*p3.y }; }
  function bezTan(p0,p1,p2,p3,u){ const m=1-u;
    return { x: 3*m*m*(p1.x-p0.x)+6*m*u*(p2.x-p1.x)+3*u*u*(p3.x-p2.x),
             y: 3*m*m*(p1.y-p0.y)+6*m*u*(p2.y-p1.y)+3*u*u*(p3.y-p2.y) }; }

  /* ---------- the kaleidoscope compositor ---------- */
  function compose(rot) {
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.fillStyle = curPal.deep;
    ctx.fillRect(0,0,W,H);

    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(rot);

    const half = SLICE/2;
    for (let i=0;i<SIDES;i++){
      ctx.save();
      ctx.rotate(i*SLICE);
      if (i % 2 === 1) ctx.scale(1,-1);        // mirror alternate wedges -> seamless
      // clip a wedge spanning [0, SLICE]
      ctx.beginPath();
      ctx.moveTo(0,0);
      ctx.arc(0,0,R, -0.001, SLICE+0.001);
      ctx.closePath();
      ctx.clip();
      // draw the source square so its center sits at the kaleidoscope center
      ctx.drawImage(src, -R, -R, R*2, R*2);
      ctx.restore();
    }
    ctx.restore();

    // gilt center hub
    const hub = ctx.createRadialGradient(cx,cy,0,cx,cy,R*0.10);
    hub.addColorStop(0, rgbaOf(curPal.accent,0.55));
    hub.addColorStop(0.5, rgbaOf(curPal.gold,0.18));
    hub.addColorStop(1, rgbaOf(curPal.gold,0));
    ctx.fillStyle = hub;
    ctx.beginPath(); ctx.arc(cx,cy,R*0.10,0,Math.PI*2); ctx.fill();

    // vignette for depth
    const vg = ctx.createRadialGradient(cx,cy,R*0.35,cx,cy,R*0.98);
    vg.addColorStop(0,"rgba(0,0,0,0)");
    vg.addColorStop(1,"rgba(0,0,0,0.55)");
    ctx.fillStyle = vg;
    ctx.fillRect(0,0,W,H);
  }

  /* ---------- animation loop (time-based, 60fps) ---------- */
  let t = 0, last = performance.now(), spin = 0;
  function frame(now) {
    let dt = (now - last) / 1000;
    last = now;
    if (dt > 0.05) dt = 0.05;                  // clamp huge first/hidden-tab deltas
    t += dt;

    // smooth pointer + warp
    wx += (mx - wx) * Math.min(dt*6,1);
    wy += (my - wy) * Math.min(dt*6,1);
    warp += (warpTarget - warp) * Math.min(dt*4,1);

    // palette crossfade
    if (palT < 1) { palT = Math.min(palT + dt/0.8, 1); }
    curPal = mixPalette(fromPal, toPal, palT<1 ? palT*palT*(3-2*palT) : 1);

    // master rotation: gentle idle drift + pointer-driven push
    spin += dt * (0.05 + wx * 0.55);
    const rot = spin + wy * 0.4;

    // the source swirls & the motif amplitude opens up with pointer distance
    const swirl = t*0.15 + wx*1.6;
    const amp = warp * 0.9;

    drawSource(t, curPal, swirl, amp);
    compose(rot);

    requestAnimationFrame(frame);
  }

  document.getElementById("ui").style.setProperty("--accent", PALETTES[0].accent);
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>