{"id":"20_singapore_glass","name":"Singapore Glass Storm","width":360,"height":420,"cornerRadius":0,"deviceFrame":"none","fit":"Layout","responsive":{"enabled":true,"mode":"constraint","width":{"type":"fixed"},"height":{"type":"fixed"},"maintainAspectRatio":false,"scaleObjects":false,"breakpoints":[{"id":"bp_mobile","name":"Mobile","minWidth":0,"maxWidth":599},{"id":"bp_tablet","name":"Tablet","minWidth":600,"maxWidth":1023},{"id":"bp_desktop","name":"Desktop","minWidth":1024}]},"backgroundColor":"transparent","opacity":1,"x":-180,"y":-210,"stateMachine":{"inputs":[{"id":"city","name":"city","value":{"type":"Text","value":"Singapore","defaultValue":"Singapore"}},{"id":"condition","name":"condition","value":{"type":"Text","value":"Thunderstorms","defaultValue":"Thunderstorms"}},{"id":"tempC","name":"tempC","value":{"type":"Number","value":30,"defaultValue":30,"min":-60,"max":60}},{"id":"unit","name":"unit","value":{"type":"Text","value":"C","defaultValue":"C"}},{"id":"skyTop","name":"skyTop","value":{"type":"Color","value":"#1d0b3f","defaultValue":"#1d0b3f"}},{"id":"skyBottom","name":"skyBottom","value":{"type":"Color","value":"#0a1c36","defaultValue":"#0a1c36"}},{"id":"blobA","name":"blobA","value":{"type":"Color","value":"#7c3aed","defaultValue":"#7c3aed"}},{"id":"blobB","name":"blobB","value":{"type":"Color","value":"#06b6d4","defaultValue":"#06b6d4"}},{"id":"blobC","name":"blobC","value":{"type":"Color","value":"#ec4899","defaultValue":"#ec4899"}},{"id":"boltGlow","name":"boltGlow","value":{"type":"Color","value":"#c4b5fd","defaultValue":"#c4b5fd"}},{"id":"humidity","name":"humidity","value":{"type":"Number","value":92,"defaultValue":92,"min":0,"max":100}},{"id":"rainChance","name":"rainChance","value":{"type":"Number","value":80,"defaultValue":80,"min":0,"max":100}},{"id":"uv","name":"uv","value":{"type":"Number","value":9,"defaultValue":9,"min":0,"max":12}},{"id":"textColor","name":"textColor","value":{"type":"Color","value":"#ffffff","defaultValue":"#ffffff"}},{"id":"radius","name":"radius","value":{"type":"Number","value":32,"defaultValue":32,"min":0,"max":60}},{"id":"intensity","name":"intensity","value":{"type":"Number","value":1,"defaultValue":1,"min":0,"max":2}},{"id":"lift","name":"lift","value":{"type":"Number","value":8,"defaultValue":8,"min":0,"max":24}},{"id":"interactive","name":"interactive","value":{"type":"Boolean","value":true,"defaultValue":true}},{"id":"tilt","name":"tilt","value":{"type":"Number","value":0,"defaultValue":0,"min":0,"max":12}},{"id":"press","name":"press","value":{"type":"Number","value":1,"defaultValue":1,"min":0,"max":2}},{"id":"float","name":"float","value":{"type":"Number","value":3,"defaultValue":3,"min":0,"max":12}},{"id":"speed","name":"speed","value":{"type":"Number","value":1,"defaultValue":1,"min":0,"max":3}},{"id":"intro","name":"intro","value":{"type":"Boolean","value":true,"defaultValue":true}},{"id":"introStyle","name":"introStyle","value":{"type":"Text","value":"rise","defaultValue":"rise"}},{"id":"introDuration","name":"introDuration","value":{"type":"Number","value":1.2,"defaultValue":1.2,"min":0.2,"max":3}},{"id":"colorTween","name":"colorTween","value":{"type":"Number","value":0.6,"defaultValue":0.6,"min":0,"max":3}},{"id":"pageMode","name":"pageMode","value":{"type":"Text","value":"none","defaultValue":"none"}},{"id":"pageTop","name":"pageTop","value":{"type":"Color","value":"#f4f1ea","defaultValue":"#f4f1ea"}},{"id":"pageBottom","name":"pageBottom","value":{"type":"Color","value":"#ddd5c4","defaultValue":"#ddd5c4"}},{"id":"replay","name":"replay","value":{"type":"Trigger","value":false,"defaultValue":false}},{"id":"tap","name":"tap","value":{"type":"Trigger","value":false,"defaultValue":false}}],"layers":[{"name":"Base Layer","entryStateId":"state_idle","states":[{"id":"state_idle","name":"Idle","transitions":[]}]}],"logicNodes":[]},"physics":{"enabled":false,"gravity":{"x":0,"y":9.8},"wind":{"x":0,"y":0},"timeScale":1,"collideWithBounds":true,"joints":[]},"guides":{"horizontal":[],"vertical":[]},"animations":[{"id":"anim_20_singapore_glass_onload","name":"onLoad","duration":3,"tracks":[]}],"layoutProps":{"mode":"linear","direction":"vertical","gap":0,"padding":{"top":0,"right":0,"bottom":0,"left":0},"alignItems":"stretch","layoutSizingHorizontal":"fixed","layoutSizingVertical":"fixed"},"animationFlow":[],"navigation":[],"stateVariables":[],"lastModified":1793007575707,"logicEditorNodes":[],"logicEditorEdges":[],"stickyNotes":[],"objects":[{"id":"20_singapore_glass_stage","name":"Singapore Glass Storm","type":"Group","transform":{"x":180,"y":210,"rotation":0,"scale_x":1,"scale_y":1,"opacity":1,"skew_x":0,"skew_y":0},"width":360,"height":420,"geometry":{"type":"Rectangle","width":360,"height":420,"cornerRadius":0},"cornerRadius":0,"children":[],"clipContent":true,"style":{"fill":{"type":"Solid","color":"#f4f1ea","opacity":0},"effects":[{"id":"fx","type":"CustomEffect","name":"singapore-glass","isEnabled":true,"params":{"city":"Singapore","condition":"Thunderstorms","tempC":30,"unit":"C","skyTop":"#1d0b3f","skyBottom":"#0a1c36","blobA":"#7c3aed","blobB":"#06b6d4","blobC":"#ec4899","boltGlow":"#c4b5fd","humidity":92,"rainChance":80,"uv":9,"textColor":"#ffffff","radius":32,"intensity":1,"lift":8,"interactive":true,"tilt":0,"press":1,"float":3,"speed":1,"intro":true,"introStyle":"rise","introDuration":1.2,"colorTween":0.6,"pageMode":"none","pageTop":"#f4f1ea","pageBottom":"#ddd5c4"},"code":"export const name = \"Singapore Glass Storm\";\nexport const params = {\n  city: { value: \"Singapore\", label: \"City\" },\n  condition: { value: \"Thunderstorms\", label: \"Condition\" },\n  tempC: { value: 30, min: -60, max: 60, step: 1, label: \"Temperature (°C)\" },\n  unit: { value: \"C\", options: [\"C\", \"F\"], label: \"Unit\" },\n  skyTop: { value: \"#1d0b3f\", label: \"Base top\" },\n  skyBottom: { value: \"#0a1c36\", label: \"Base bottom\" },\n  blobA: { value: \"#7c3aed\", label: \"Glow 1\" },\n  blobB: { value: \"#06b6d4\", label: \"Glow 2\" },\n  blobC: { value: \"#ec4899\", label: \"Glow 3\" },\n  boltGlow: { value: \"#c4b5fd\", label: \"Lightning glow\" },\n  humidity: { value: 92, min: 0, max: 100, step: 1, label: \"Humidity %\" },\n  rainChance: { value: 80, min: 0, max: 100, step: 1, label: \"Rain chance %\" },\n  uv: { value: 9, min: 0, max: 12, step: 1, label: \"UV index\" },\n  textColor: { value: \"#ffffff\", label: \"Text\" },\n  radius: { value: 32, min: 0, max: 60, step: 1, label: \"Corner radius\" },\n  intensity: { value: 1, min: 0, max: 2, step: 0.1, label: \"Particle intensity\" },\n  lift: { value: 8, min: 0, max: 24, step: 1, label: \"Hover lift\" },\n  interactive: { value: true, label: \"Mouse interaction\" },\n  tilt: { value: 0, min: 0, max: 12, step: 0.5, label: \"Hover tilt (deg, 0 = off)\" },\n  press: { value: 1, min: 0, max: 2, step: 0.1, label: \"Press squish\" },\n  float: { value: 3, min: 0, max: 12, step: 0.5, label: \"Idle float (px)\" },\n  speed: { value: 1, min: 0, max: 3, step: 0.1, label: \"Animation speed\" },\n  intro: { value: true, label: \"Entrance animation\" },\n  introStyle: { value: \"rise\", options: [\"rise\", \"drop\", \"zoom\", \"swing\", \"slide\", \"none\"], label: \"Entrance style\" },\n  introDuration: { value: 1.2, min: 0.2, max: 3, step: 0.1, label: \"Entrance duration (s)\" },\n  colorTween: { value: 0.6, min: 0, max: 3, step: 0.1, label: \"Colour change fade (s)\" },\n  pageMode: { value: \"none\", options: [\"solid\", \"gradient\", \"none\"], label: \"Page background\" },\n  pageTop: { value: \"#f4f1ea\", label: \"Page colour\" },\n  pageBottom: { value: \"#ddd5c4\", label: \"Page gradient end\" }\n};\nlet S = null;\nlet KS = null;\nfunction rr(ctx, x, y, w, h, r) {\n  r = Math.max(0, Math.min(r, w / 2, h / 2));\n  ctx.beginPath();\n  ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);\n  ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);\n  ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);\n  ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath();\n}\nfunction rnd(i) { const s = Math.sin(i * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }\n/* accepts #rgb, #rrggbb, #rrggbbaa, rgb() and rgba() */\nfunction hx(c) {\n  c = String(c).trim();\n  if (c.charAt(0) === '#') {\n    let h = c.slice(1);\n    if (h.length === 3 || h.length === 4) h = h.split('').map(function (ch) { return ch + ch; }).join('');\n    const n = parseInt(h.slice(0, 6), 16) || 0;\n    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n  }\n  const m = c.match(/[\\d.]+/g);\n  return m && m.length >= 3 ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0];\n}\nfunction isCol(v) { return typeof v === 'string' && /^(#[0-9a-f]{3,8}|rgba?\\()/i.test(v.trim()); }\nfunction mix(a, b, k) { const A = hx(a), B = hx(b); const r = A.map(function (v, i) { return Math.round(v + (B[i] - v) * k); }); return 'rgb(' + r[0] + ',' + r[1] + ',' + r[2] + ')'; }\nfunction deg(c, f) { const v = f ? Math.round(c * 9 / 5 + 32) : Math.round(c); return String(v).replace('-', '−') + '°'; }\nfunction ease(k) { return 1 - Math.pow(1 - k, 3); }\nfunction back(k) { const s = 1.7; k = k - 1; return 1 + k * k * ((s + 1) * k + s); }\n/* reads every param. A state machine input with the same name wins once it differs\n   from the file default, so the editor panel keeps working and hosts can drive it live.\n   Colour changes cross-fade; text changes replay the sheen. */\nfunction cfg(env, defs) {\n  if (!KS) KS = { col: {}, txt: {}, sheen: -9 };\n  const out = {}, now = env.time;\n  for (const k in defs) {\n    const d0 = defs[k] && typeof defs[k] === 'object' && 'value' in defs[k] ? defs[k].value : defs[k];\n    let v = env.params ? env.params[k] : undefined;\n    if (v && typeof v === 'object' && 'value' in v) v = v.value;\n    const iv = env.inputs ? env.inputs[k] : undefined;\n    if (iv !== undefined && iv !== null && iv !== '' && String(iv) !== String(d0)) v = iv;\n    if (v === undefined || v === null || v === '') v = d0;\n    if (typeof d0 === 'number') { const n = Number(v); v = isNaN(n) ? d0 : n; }\n    else if (typeof d0 === 'boolean') v = v === true || v === 'true' || v === 1;\n    else v = String(v);\n    out[k] = v;\n  }\n  const tw = Math.max(0.01, out.colorTween === undefined ? 0.6 : out.colorTween);\n  for (const k in out) {\n    const v = out[k];\n    if (isCol(v)) {\n      let s = KS.col[k];\n      if (!s) s = KS.col[k] = { from: v, to: v, t0: -99 };\n      if (s.to !== v) { s.from = s.cur || s.to; s.to = v; s.t0 = now; }\n      const p = Math.min(1, (now - s.t0) / tw);\n      s.cur = p >= 1 ? v : mix(s.from, s.to, ease(p));\n      out[k] = s.cur;\n    } else if (typeof v === 'string' && k !== 'unit' && k !== 'introStyle' && k !== 'pageMode') {\n      if (KS.txt[k] !== undefined && KS.txt[k] !== v) KS.sheen = now;\n      KS.txt[k] = v;\n    }\n  }\n  return out;\n}\n/* hover, tap (pointer or 'tap' trigger), unit, temperature tween, entrance and sheen timing */\nfunction card(S, env, P) {\n  const now = env.time, inp = env.inputs || {};\n  if (S.t0 === undefined) { S.t0 = now; S.tilt = 0; S.press = 0; S.tFrom = P.tempC; S.tTo = P.tempC; S.tt0 = -99; S.trg = {}; }\n  if (S.unit !== P.unit) { S.unit = P.unit; S.f = P.unit === 'F'; }\n  const trig = function (n) { const v = !!inp[n]; const r = v && !S.trg[n]; S.trg[n] = v; return r; };\n  if (trig('replay')) S.t0 = now;\n  /* interactive off: the piece ignores the pointer (hover, tilt, parallax, press, click);\n     the tap and replay triggers still work from code */\n  const pt = P.interactive === false ? {} : (env.pointer || {});\n  const over = !!pt.isHovered && Math.abs(pt.x || 0) <= 150 && Math.abs(pt.y || 0) <= 180;\n  S.lift += ((over ? 1 : 0) - S.lift) * 0.12;\n  S.tilt += ((over ? (pt.x || 0) / 150 : 0) - S.tilt) * 0.1;\n  const down = !!pt.isDown;\n  const tap = (down && !S.wasDown && over) || trig('tap');\n  S.wasDown = down;\n  S.press += ((down && over ? 1 : 0) - S.press) * 0.3;\n  if (S.tTo !== P.tempC) { S.tFrom = S.tShown === undefined ? S.tTo : S.tShown; S.tTo = P.tempC; S.tt0 = now; }\n  const tp = Math.min(1, (now - S.tt0) / 0.9);\n  S.tShown = S.tFrom + (S.tTo - S.tFrom) * ease(tp);\n  const k = P.intro && P.introStyle !== 'none' ? Math.min(1, Math.max(0, (now - S.t0) / Math.max(0.05, P.introDuration))) : 1;\n  const e = ease(k);\n  const L = S.lift * P.lift;\n  const fl = P.float * Math.sin(now * P.speed * Math.PI / 2);\n  const sk = Math.min(1, Math.max(0, (now - KS.sheen) / 0.9));\n  return { over: over, tap: tap, L: L, px: pt.x || 0, py: pt.y || 0, k: k, e: e, sk: sk, T: -180 - L + fl,\n    temp: S.tShown * Math.min(1, e * 1.04), S: S };\n}\n/* page background, then the card transform: entrance style, hover tilt, press squish */\nfunction begin(ctx, c, P) {\n  const pg = ctx.createLinearGradient(0, -260, 0, 260);\n  pg.addColorStop(0, P.pageTop); pg.addColorStop(1, P.pageMode === 'gradient' ? P.pageBottom : P.pageTop);\n  ctx.globalAlpha = P.pageMode === 'none' ? 0 : 1;\n  ctx.fillStyle = pg; ctx.fillRect(-360, -260, 720, 520);\n  ctx.globalAlpha = 1;\n  ctx.save();\n  const k = c.k, e = c.e, st = P.introStyle;\n  let dx = 0, dy = 0, s = 1, rot = 0;\n  if (st === 'rise') { dy = (1 - e) * 46; s = 0.9 + 0.1 * e; }\n  else if (st === 'drop') { dy = (back(k) - 1) * 90; }\n  else if (st === 'zoom') { s = 0.55 + 0.45 * back(k); }\n  else if (st === 'swing') { rot = (1 - back(k)) * 0.35; dy = (1 - e) * 20; }\n  else if (st === 'slide') { dx = (1 - back(k)) * 140; }\n  rot += c.S.tilt * P.tilt * Math.PI / 180;\n  s *= 1 - 0.035 * c.S.press * P.press;\n  ctx.translate(dx, dy); ctx.rotate(rot); ctx.scale(s, s);\n}\n/* entrance sheen (also replays when a text setting changes) */\nfunction end(ctx, c, P) {\n  ctx.save(); rr(ctx, -150, c.T, 300, 360, P.radius === undefined ? 4 : P.radius); ctx.clip();\n  const q = c.k < 1 ? c.k : c.sk;\n  const sx = -260 + q * 520;\n  const g = ctx.createLinearGradient(sx - 60, c.T, sx + 60, c.T + 120);\n  g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,0.55)'); g.addColorStop(1, 'rgba(255,255,255,0)');\n  ctx.globalAlpha = q < 1 ? 0.8 * Math.sin(q * Math.PI) : 0;\n  ctx.fillStyle = g; ctx.fillRect(-150, c.T, 300, 360);\n  ctx.globalAlpha = 1; ctx.restore();\n  ctx.restore();\n}\nfunction shadowOn(ctx, S, c) { ctx.shadowColor = c || 'rgba(20,30,60,0.35)'; ctx.shadowBlur = 30 + 20 * S.lift; ctx.shadowOffsetY = 16 + 8 * S.lift; }\nfunction shadowOff(ctx) { ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; }\nexport function render(ctx, env) {\n  const P = cfg(env, params), t = env.time * P.speed;\n  if (!S) S = { lift: 0, wasDown: false, bolt: 0 };\n  const c = card(S, env, P), x0 = -150, T = c.T;\n  begin(ctx, c, P);\n  if (c.tap) S.bolt = 1;\n  S.bolt *= 0.9;\n  /* flash: one natural strike every 4 s, plus on tap */\n  const ph = (t % 4);\n  const flash = Math.max(S.bolt, ph > 3.2 && ph < 3.3 ? 1 : ph > 3.38 && ph < 3.44 ? 0.6 : 0);\n  /* deep storm base */\n  shadowOn(ctx, S, 'rgba(40,0,80,0.45)');\n  rr(ctx, x0, T, 300, 360, P.radius);\n  const bg = ctx.createLinearGradient(0, T, 0, T + 360);\n  bg.addColorStop(0, P.skyTop); bg.addColorStop(1, P.skyBottom);\n  ctx.fillStyle = bg; ctx.fill(); shadowOff(ctx);\n  ctx.save(); rr(ctx, x0, T, 300, 360, P.radius); ctx.clip();\n  /* colour blobs behind the glass (4 s loop) */\n  const blobs = [[0 - 80, 0 - 90, 130, P.blobA], [90, 0 - 30, 120, P.blobB], [0 - 40, 110, 130, P.blobC]];\n  for (let i = 0; i < 3; i++) {\n    const b = blobs[i], bx = b[0] + 20 * Math.sin(t * Math.PI / 2 + i * 2), by = b[1] - c.L + 16 * Math.cos(t * Math.PI / 2 + i);\n    const gr = ctx.createRadialGradient(bx, by, 0, bx, by, b[2]);\n    gr.addColorStop(0, b[3]); gr.addColorStop(1, 'rgba(0,0,0,0)');\n    ctx.globalAlpha = 0.85; ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(bx, by, b[2], 0, Math.PI * 2); ctx.fill();\n  }\n  ctx.globalAlpha = 1;\n  /* frosted glass layer, brightens with each flash */\n  rr(ctx, x0 + 1, T + 1, 298, 358, 31);\n  ctx.fillStyle = 'rgba(255,255,255,' + (0.1 + flash * 0.3) + ')'; ctx.fill();\n  ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1.2; ctx.stroke();\n  /* lightning bolt */\n  ctx.globalAlpha = flash; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 3; ctx.lineJoin = 'round';\n  ctx.shadowColor = P.boltGlow; ctx.shadowBlur = 18;\n  ctx.beginPath(); ctx.moveTo(x0 + 190, T); ctx.lineTo(x0 + 172, T + 50); ctx.lineTo(x0 + 196, T + 58); ctx.lineTo(x0 + 168, T + 120); ctx.lineTo(x0 + 186, T + 124); ctx.lineTo(x0 + 160, T + 176); ctx.stroke();\n  shadowOff(ctx); ctx.globalAlpha = 1;\n  /* condensation beads sliding down (4 s loop) */\n  for (let i = 0; i < Math.round(14 * P.intensity); i++) {\n    const p = (t / 4 * (0.5 + rnd(i)) + rnd(i + 3)) % 1;\n    const x = x0 + 16 + rnd(i + 7) * 268, y = T + p * 380 - 10, r = 2 + rnd(i + 1) * 3;\n    ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();\n    ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(x - r * 0.35, y - r * 0.35, r * 0.35, 0, Math.PI * 2); ctx.fill();\n  }\n  ctx.restore();\n  ctx.fillStyle = P.textColor; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';\n  ctx.font = '600 13px Avenir Next, Helvetica Neue, sans-serif';\n  ctx.fillText(P.city || 'Singapore', x0 + 26, T + 42);\n  ctx.globalAlpha = 0.7; ctx.font = '500 11px Avenir Next, Helvetica Neue, sans-serif';\n  ctx.fillText(P.condition + ' · tap for thunder', x0 + 26, T + 60); ctx.globalAlpha = 1;\n  ctx.font = '200 96px Avenir Next, Helvetica Neue, sans-serif';\n  ctx.fillText(deg(c.temp, S.f), x0 + 20, T + 280);\n  /* glass chips */\n  const chips = [['Humidity', P.humidity + '%'], ['Rain', P.rainChance + '%'], ['UV', String(P.uv)]];\n  for (let i = 0; i < 3; i++) {\n    const x = x0 + 22 + i * 88;\n    ctx.fillStyle = 'rgba(255,255,255,0.18)'; rr(ctx, x, T + 302, 80, 40, 14); ctx.fill();\n    ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = '500 10px Avenir Next, Helvetica Neue, sans-serif'; ctx.fillText(chips[i][0], x + 12, T + 318);\n    ctx.fillStyle = '#ffffff'; ctx.font = '700 14px Avenir Next, Helvetica Neue, sans-serif'; ctx.fillText(chips[i][1], x + 12, T + 334);\n  }\n  end(ctx, c, P);\n}\n"}]},"layoutProps":{"layoutSizingHorizontal":"fill","layoutSizingVertical":"fill"},"zIndex":1,"interactions":[],"isExpanded":true}]}