File size: 9,070 Bytes
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import { esc } from "./math.js";
// The sky look's pictures. The owl is mathly1's (static/math-sky.js), coloured by the --sky-owl* tokens in app.css.
export const owl = (width = 80, cls = "") => `<svg class="sky-owl ${cls}" width="${width}" height="${Math.round(width * 1.04)}" viewBox="0 0 100 104" aria-hidden="true">
  <path d="M20 34 26 6l17 18q7-3 14 0L74 6l6 28q12 22 4 44-9 24-34 24T16 78q-8-22 4-44Z" class="o-b"/>
  <path d="M16 62q-8 14 2 28 4-10 4-22Z M84 62q8 14-2 28-4-10-4-22Z" class="o-b" opacity=".75"/>
  <ellipse cx="50" cy="74" rx="22" ry="23" class="o-belly"/>
  <path d="M40 70q3 3 6 0M54 70q3 3 6 0M44 80q3 3 6 0M50 80q3 3 6 0" class="o-feather"/>
  <circle cx="35" cy="44" r="13" class="o-eye"/><circle cx="65" cy="44" r="13" class="o-eye"/>
  <circle cx="37" cy="45" r="6.5" class="o-pupil"/><circle cx="63" cy="45" r="6.5" class="o-pupil"/>
  <circle cx="39" cy="42.5" r="2" class="o-glint"/><circle cx="65" cy="42.5" r="2" class="o-glint"/>
  <ellipse class="o-lid" cx="35" cy="44" rx="13.5" ry="13.5"/><ellipse class="o-lid" cx="65" cy="44" rx="13.5" ry="13.5"/>
  <circle cx="35" cy="44" r="15.5" class="o-glass"/><circle cx="65" cy="44" r="15.5" class="o-glass"/>
  <path d="M48 42q2-2 4 0" class="o-glass"/>
  <path d="M45 56h10l-5 9Z" class="o-beak"/>
  <path d="M38 99l-3 4M44 100v4M56 100v4M62 99l3 4" class="o-feet"/></svg>`;

/** The four-pointed star every skill is (mathly1's spark). Its colour and fill come from CSS (state classes). */
export const spark = (cls = "") => `<svg viewBox="0 0 24 24" class="${cls}" aria-hidden="true"><path d="M12 1.5C13.1 9 15 10.9 22.5 12C15 13.1 13.1 15 12 22.5C10.9 15 9 13.1 1.5 12C9 10.9 10.9 9 12 1.5Z"/></svg>`;

/** The arcs that join a lesson's stars in a ring (from mathly1's orbitRing). One path per neighbouring pair, clockwise from the top;
 *  an arc is "on" once the star it leaves has been started. The closing arc from the last star back to the first stays faint:
 *  the ring has a start and an end. Drawn in a 100x100 box that is stretched to the ring's size, so strokes do not scale. */
export function ringSvg(states) {
  const n = states.length;
  if (n < 2) return "";
  const angle = i => (-90 + 360 * i / n) * Math.PI / 180;
  const gap = Math.min(0.32, Math.PI / n * 0.55);          // room left around each star (radians)
  const point = t => [50 + 50 * Math.cos(t), 50 + 50 * Math.sin(t)];
  const f = v => v.toFixed(2);
  const arc = (from, to, cls) => {
    const [x0, y0] = point(from), [x1, y1] = point(to);
    return `<path class="${cls}" d="M${f(x0)} ${f(y0)}A50 50 0 0 1 ${f(x1)} ${f(y1)}"/>`;
  };
  const arcs = states.slice(0, -1).map((state, k) => arc(angle(k) + gap, angle(k + 1) - gap, state === "not_started" ? "" : "on")).join("");
  const back = n > 2 ? arc(angle(n - 1) + gap, angle(n) - gap, "back") : "";
  return `<svg class="ring" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${back}${arcs}</svg>`;
}

/* ---------- constellations and comets (ported from mathly1) ---------- */
const SPARK = "M12 1.5C13.1 9 15 10.9 22.5 12C15 13.1 13.1 15 12 22.5C10.9 15 9 13.1 1.5 12C9 10.9 10.9 9 12 1.5Z";
const curve=(f,n=48)=>Array.from({length:n+1},(_,i)=>f(i/n));
const SHAPES={
  t:{name:"ื”ืงืขืจื”",aspect:.9,pts:curve(s=>[1-s,1-(2*s-1)**2])},
  t2:{name:"ื”ืžื“ืจื’ื•ืช",aspect:.75,corners:1,pts:[[1,1],[1,.5],[.5,.5],[.5,0],[0,0]]},
  k:{name:"ื”ื“ืœืชื•ืŸ",aspect:1.15,closed:1,corners:1,pts:[[.5,0],[1,.36],[.5,1],[0,.36]],inner:[[[.5,0],[.5,1]],[[0,.36],[1,.36]]]},
  i:{name:"ื”ืžืฉื•ืœืฉ",aspect:.9,closed:1,corners:1,pts:[[.5,0],[1,1],[0,1]],inner:[[[.5,0],[.5,1]]]},
  q:{name:"ื”ืงืฉืช",aspect:.75,pts:curve(s=>[1-s,(2*s-1)**2])},
  q2:{name:"ื”ืคืจื‘ื•ืœื”",aspect:.85,pts:curve(s=>[1-s,1-(2*s-1)**2]),inner:[[[.5,0],[.5,1]]]},
  a:{name:"ื”ื–ื•ื•ื™ืช",aspect:.7,closed:1,corners:1,pts:[[1,0],[1,1],[0,1]]},
  l:{name:"ื”ืžืฉื•ืœืฉ ื”ื™ืฉืจ",aspect:.75,closed:1,corners:1,pts:[[1,0],[1,1],[0,1]]},
  z:{name:"ื”ื˜ืจืคื–",aspect:.6,closed:1,corners:1,pts:[[.72,0],[1,1],[0,1],[.28,0]]},
  z2:{name:"ื”ื˜ืจืคื– ื”ื™ืฉืจ",aspect:.6,closed:1,corners:1,pts:[[.6,0],[1,1],[0,1],[0,0]]},
  e:{name:"ื”ื’ื‘ืขื”",aspect:.55,pts:curve(s=>[1-s,1-Math.sin(Math.PI*s)])},
  v:{name:"ื”ื—ืฅ",aspect:.8,corners:1,pts:[[0,0],[1,.5],[0,1]]},
  g:{name:"ื”ืžืงื‘ื™ืœื™ืช",aspect:.6,closed:1,corners:1,pts:[[1,0],[.72,1],[0,1],[.28,0]]},
  r:{name:"ื”ื”ื™ืคืจื‘ื•ืœื”",aspect:.9,pts:curve(s=>{const x=.06+.94*(1-s);return [x,Math.min(1,.06/x)]})},
  m:{name:"ื”ืžืœื‘ืŸ",aspect:.62,closed:1,corners:1,pts:[[1,0],[1,1],[0,1],[0,0]],inner:[[[1,0],[0,1]]]},
  h:{name:"ื”ืงื•ื‘ื™ื™ื”",aspect:1.1,closed:1,corners:1,pts:[[.5,0],[1,.25],[1,.75],[.5,1],[0,.75],[0,.25]],inner:[[[.5,.5],[.5,1]],[[.5,.5],[0,.25]],[[.5,.5],[1,.25]]]},
  s:{name:"ื”ืžืคื’ืฉ",aspect:.75,pts:curve(s=>[1-s,(2*s-1)**2]),inner:[[[1,.12],[0,.88]]]},
  n:{name:"ื”ื’ืœ",aspect:.5,pts:curve(s=>[1-s,.5-.5*Math.sin(2*Math.PI*s)])},
  b:{name:"ื”ืžืขื•ื™ืŸ",aspect:1.25,closed:1,corners:1,pts:[[.5,0],[1,.5],[.5,1],[0,.5]],inner:[[[.5,0],[.5,1]],[[0,.5],[1,.5]]]},
  c:{name:"ื”ืจื™ื‘ื•ืข",aspect:1,closed:1,corners:1,pts:[[1,0],[1,1],[0,1],[0,0]],inner:[[[1,0],[0,1]],[[0,0],[1,1]]]},
  o:{name:"ื”ืชื™ื›ื•ืŸ",aspect:.8,closed:1,corners:1,pts:[[1,0],[1,1],[0,1]],inner:[[[1,1],[.5,.5]]]},
  p:{name:"ื”ืจืงื˜ื”",aspect:1,pts:curve(s=>{const x=1-s;return [x,1-(Math.pow(40,x)-1)/39]})},
  w:{name:"ื”ื‘ืจืง",aspect:.6,corners:1,pts:[[1,1],[.75,0],[.5,.75],[.25,0],[0,1]]},
  d:{name:"ื”ืžืฉื•ืœืฉื™ื ื”ื“ื•ืžื™ื",aspect:.9,closed:1,corners:1,pts:[[.5,0],[1,1],[0,1]],inner:[[[.25,.5],[.75,.5]]]},
};
const FALLBACK={aspect:.7,corners:1,pts:[[1,.15],[.68,.85],[.36,.1],[0,.75]]};
const W=360, X0=60, X1=300, TOP=52, BOTTOM=64;
const lerp=(a,b,t)=>[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];
const dist=(a,b)=>Math.hypot(b[0]-a[0],b[1]-a[1]);
function segments(pts,closed){ const out=[]; for(let i=0;i<pts.length-1;i++) out.push([pts[i],pts[i+1]]); if(closed) out.push([pts[pts.length-1],pts[0]]); return out; }
function along(pts,closed,n){
  const segs=segments(pts,closed), L=segs.reduce((s,[a,b])=>s+dist(a,b),0);
  const at=d=>{ for(const [a,b] of segs){ const l=dist(a,b); if(d<=l+1e-9) return lerp(a,b,l?d/l:0); d-=l; } return segs[segs.length-1][1]; };
  if(n<=0) return [];
  if(closed) return Array.from({length:n},(_,i)=>at(L*i/n));
  if(n===1) return [at(L/2)];
  return Array.from({length:n},(_,i)=>at(L*i/(n-1)));
}
function onCorners(pts,closed,n){
  const segs=segments(pts,closed), extra=segs.map(()=>0);
  for(let k=0;k<n-pts.length;k++){ let best=0; segs.forEach(([a,b],i)=>{ if(dist(a,b)/(extra[i]+1)>dist(...segs[best])/(extra[best]+1)) best=i; }); extra[best]++; }
  const out=[];
  segs.forEach(([a,b],i)=>{ out.push(a); for(let j=1;j<=extra[i];j++) out.push(lerp(a,b,j/(extra[i]+1))); });
  if(!closed) out.push(pts[pts.length-1]);
  return out;
}
/* The drawing of unit `id` with `n` stars, in a box W wide and H high: the stars in the path's order, the outline's
   segments, and the inner lines. A unit with many stars is drawn taller so that they keep apart. */
export function shape(id,n,name){
  const S=SHAPES[id]||{...FALLBACK,name};
  const w=X1-X0, h=Math.round(Math.max(w*S.aspect,n>6?(n-1)*36:0)), H=TOP+h+BOTTOM;
  const map=([x,y])=>[X0+x*w,TOP+y*h];
  const pts=S.pts.map(map);
  const stars=(S.corners&&n>=pts.length?onCorners(pts,!!S.closed,n):along(pts,!!S.closed,n));
  return {name:S.name||name,H,stars,lines:segments(pts,!!S.closed),inner:(S.inner||[]).map(l=>l.map(map))};
}

/** A completed lesson's constellation: its outline and inner lines, and its stars as small gold sparks, in the shape its lesson id gives
 *  (a zig-zag when none is defined). Stars are numbered in the lesson's order for the screen reader only. */
export function constellationSvg(lessonId, name, count) {
  const shp = shape(lessonId, count, name);
  const line = ([a, b]) => `<line x1="${a[0].toFixed(1)}" y1="${a[1].toFixed(1)}" x2="${b[0].toFixed(1)}" y2="${b[1].toFixed(1)}"/>`;
  const stars = shp.stars.map(([x, y]) => `<path d="${SPARK}" transform="translate(${(x - 20).toFixed(1)} ${(y - 20).toFixed(1)}) scale(${(20 / 12).toFixed(3)})"/>`).join("");
  return `<svg class="con" viewBox="34 22 292 ${shp.H - 56}" role="img" aria-label="ืงื‘ื•ืฆืช ื”ื›ื•ื›ื‘ื™ื ${name}: ${count} ื›ื•ื›ื‘ื™ื">${[...shp.lines, ...shp.inner].map(line).join("")}${stars}</svg>`;
}

/** The comet that crosses the screen: a gold tail and a spark. */
export const cometSvg = () => `<svg viewBox="0 0 120 56" aria-hidden="true"><defs><linearGradient id="cometTail" x1="0" x2="1"><stop offset="0" stop-color="#F7C948" stop-opacity="0"/><stop offset="1" stop-color="#F7C948"/></linearGradient></defs>
  <path d="M2 28 98 20 98 36Z" fill="url(#cometTail)"/><path d="${SPARK}" transform="translate(84 10) scale(1.5)" fill="#EAF0FF"/></svg>`;

/** A caught comet's card: its symbol and name (the fact is written under it by the screen). */
export const cometCard = c => `<div class="comet-card"><span class="sym" dir="ltr">${esc(c.sym)}</span><b>${esc(c.name)}</b></div>`;