← canonical-projection-continued-fractions
Substitution across the octagonal family
Flexible substitution rules for a family of canonical projection tilings
Source
octagonalSubstitution/index.js
/**
* octagonalSubstitution -- the two generators of the octagonal continued
* fraction as ONE figure, over the whole band of tile shapes.
*
* The family: axis edges a at 0deg and 90deg, diagonal edges b at 45deg and
* 135deg, and the single modulus rho = b : a. Relabelling which cross is the
* axis one is a symmetry of the family and inverts rho, so every shape appears
* exactly once with the DIAGONAL SHORTER, rho in (0, 1]: the slider is that
* rho, running from the collapsed diagonal to the classical Ammann-Beenker
* tiling at rho = 1.
*
* Two lattice maps generate the continued fraction on this modulus, and each
* owns half of the band:
*
* rho in [1/sqrt2, 1] the SILVER INFLATION, multiplication by 1 + sqrt2.
* The inflated edges are A = a + sqrt2 b and C likewise, B = sqrt2 a + b
* and D likewise, so the ratio goes to (sqrt2 + rho)/(1 + sqrt2 rho) -- the
* Moebius map with the classical tiling as its fixed point. This is the
* flexible Ammann-Beenker substitution.
*
* rho in (0, 1/sqrt2) the PARABOLIC SHEAR, the unipotent matrix
* M = ((1,0,0,0),(1,1,-1,0),(0,0,1,0),(1,0,1,1)), which stretches the axis
* cross in place (a to (1 + sqrt2 rho) a, c likewise) and fixes the
* diagonal cross, sending rho to rho/(1 + sqrt2 rho). Inflating nothing, its
* rule is a recomposition: the supertile cuts through the base tiles around
* it and regroups them.
*
* The split at 1/sqrt2 is forced, not chosen. The shear's image window has axis
* generator 1 - sqrt2 rho, which vanishes at rho = 1/sqrt2; past that wall the
* image acceptance selects a folded surface and no single-window figure can
* show it. So the shear rule is drawn exactly where it exists, and the silver
* inflation -- which is defined across the whole family -- takes the band above
* the wall. Dragging the slider through 1/sqrt2 swaps the rule.
*
* WHAT STAYS PUT ACROSS THE WALL. Both rules are drawn in the same frame, so
* only the rule itself changes at 1/sqrt2:
* - the LEFT column of every panel is the base tile at ratio rho, marked --
* the same twelve marked oriented tiles for both generators (the axis
* square in its four mark orientations, the diagonal square in its four,
* the four rhombs), each in a fixed slot keyed by its mark direction or,
* for the rhombs, by its pair of edge directions;
* - the RIGHT column is that tile's image under the generator, decomposed
* into base tiles. Both images have axis edge 1 + sqrt2 rho, so the common
* normalisation k = 1/(1 + sqrt2 rho) holds the supertiles at near constant
* size while the base tiles sweep the family;
* - the OUTER window on the right is the window of the base tiling at ratio
* rho, the same octagon on both sides of the wall: the internal star of
* each generator's figure is the Galois conjugate rho -> -rho of the same
* physical star, and the two agree as centred zonogons. What swaps is the
* SUBWINDOW nested at its centre -- the window of the image tiling,
* partitioned into one cell per rule and drawn in blues. Each cell,
* translated by the anchor offset of each of its children, is a copy out in
* the window, and together the copies tile the window exactly: the action
* of the rule as a piecewise translation. A copy carries the colour of the
* tile it substitutes FROM.
* Colour is read off the geometry alone and so is shared by the two regimes:
* green for the axis square {0,90}, brown for the diagonal square {45,135},
* cream and tan for the two mirror classes of rhomb.
*
* The rule data lives in two sibling data modules, one per generator. The
* silver side takes the lattice rule tables, prototile panels and window
* octagons of `./flexibleAB.js` and evaluates them in JS at the current ratio;
* the parabolic side calls `./shearSubstitution.js`'s per-ratio `scene()`
* directly. Both are reduced to one class structure -- prototile,
* image outline, children, window cell, copy offsets -- pushed to CindyJS as
* CLS whenever the slider moves. The interaction state is two scalars living in
* Cindy, HLC (hovered class) and HLK (hovered child within it); the pointer is
* hit-tested against HITL through evalcs, repainting only when the state
* changes. No geometry flows back to JS. This is a site-only visualiser (a
* `_visualisers/<name>/` folder with a meta.ts) mounted by VisualiserHost via
* the mount() contract.
*/
import { _test as AB } from './flexibleAB.js';
import { _test as SH } from './shearSubstitution.js';
const CINDY_SRC = '/vendor/cindyjs/Cindy.js';
// Idempotent <script> loader: resolves once window.CindyJS exists. Shared
// across every mount on the page (the global is a singleton).
let cindyPromise = null;
function loadCindy() {
if (typeof window !== 'undefined' && window.CindyJS) return Promise.resolve();
if (cindyPromise) return cindyPromise;
cindyPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = CINDY_SRC;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`CindyJS failed to load from ${CINDY_SRC}`));
document.head.appendChild(s);
});
return cindyPromise;
}
// ── The band ─────────────────────────────────────────────────────────────────
const SQ2 = Math.SQRT2;
const WALL = Math.SQRT1_2; // where the shear's image window degenerates
const RHO_MIN = 0.001, RHO_MAX = 1, RHO_DEF = 0.85;
// ── Palette ──────────────────────────────────────────────────────────────────
// Indices 1..4 are the tile classes read off the edge directions, so both
// regimes colour the same shape the same way.
const colA = [0.502, 0.753, 0.502]; // 1 axis square {0,90}: green
const colB = [0.376, 0.188, 0.063]; // 2 diagonal square {45,135}: brown
const colR = [0.941, 0.941, 0.816]; // 3 rhombs {45,90} and {0,135}: cream
const colT = [0.871, 0.816, 0.639]; // 4 rhombs {0,45} and {90,135}: tan
const TYPECOL = [colA, colB, colR, colT];
// The window figure is drawn in blues, one shade per tile class.
const TYPEBLUE = [[0.45, 0.62, 0.85], [0.25, 0.38, 0.66],
[0.72, 0.82, 0.93], [0.58, 0.71, 0.89]];
const ink = [0.125, 0.125, 0.314];
const selCol = [50 / 255, 74 / 255, 33 / 255];
const pageBg = [1, 1, 1];
const dotFill = [1, 1, 1];
// ── Small geometry ───────────────────────────────────────────────────────────
const cadd = (a, b) => [a[0] + b[0], a[1] + b[1]];
const csc = (a, s) => [a[0] * s, a[1] * s];
const centroid = (P) => {
let x = 0, y = 0;
for (const p of P) { x += p[0]; y += p[1]; }
return [x / P.length, y / P.length];
};
const bboxOf = (P) => {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of P) {
if (p[0] < x0) x0 = p[0];
if (p[1] < y0) y0 = p[1];
if (p[0] > x1) x1 = p[0];
if (p[1] > y1) y1 = p[1];
}
return { x0, y0, x1, y1, w: x1 - x0, h: y1 - y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 };
};
// Clip a convex polygon by a convex CCW polygon (Sutherland-Hodgman).
function convexClip(poly, O) {
let out = poly;
for (let i = 0; i < O.length && out.length; i++) {
const A = O[i], B = O[(i + 1) % O.length];
const side = (p) => (B[0] - A[0]) * (p[1] - A[1]) - (B[1] - A[1]) * (p[0] - A[0]);
const nxt = [];
for (let j = 0; j < out.length; j++) {
const P = out[j], Q = out[(j + 1) % out.length], sp = side(P), sq = side(Q);
if (sp >= -1e-12) nxt.push(P);
if ((sp > 1e-12 && sq < -1e-12) || (sp < -1e-12 && sq > 1e-12)) {
const t = sp / (sp - sq);
nxt.push([P[0] + t * (Q[0] - P[0]), P[1] + t * (Q[1] - P[1])]);
}
}
out = nxt;
}
return out;
}
const dedupe = (P) => {
const out = [];
for (const p of P) {
const prev = out.length ? out[out.length - 1] : P[P.length - 1];
if (Math.hypot(p[0] - prev[0], p[1] - prev[1]) > 1e-7) out.push(p);
}
return out;
};
// ── Reading a tile off its geometry ──────────────────────────────────────────
// Every edge in the family points at an exact multiple of 45deg in both
// regimes (the shear stretches the axis cross, the inflation is a real scalar),
// so a tile's identity -- its class, its colour, its slot -- is read from
// direction indices rather than from either module's own star labelling, which
// differ (flexibleAB puts b at 45deg, shearSubstitution at -45deg).
const dirIdx = (v) => ((Math.round(Math.atan2(v[1], v[0]) / (Math.PI / 4)) % 4) + 4) % 4;
const markIdx = (v) => ((Math.round(Math.atan2(v[1], v[0]) / (Math.PI / 4)) % 8) + 8) % 8;
// A tile is a parallelogram [p, p+u, p+u+v, p+v]: its pair of edge directions.
const pairKey = (poly) => [dirIdx([poly[1][0] - poly[0][0], poly[1][1] - poly[0][1]]),
dirIdx([poly[3][0] - poly[0][0], poly[3][1] - poly[0][1]])].sort().join(',');
const PAIRCOL = { '0,2': 1, '1,3': 2, '1,2': 3, '0,3': 3, '0,1': 4, '2,3': 4 };
const colourOf = (poly) => {
const c = PAIRCOL[pairKey(poly)];
if (!c) throw new Error(`octagonalSubstitution: tile with edge pair ${pairKey(poly)}`);
return c;
};
// The twelve fixed slots, three columns of four: the axis square in its four
// mark orientations, the diagonal square in its four, then the four rhombs.
// A marked square's slot is its mark direction in reading order (top-left,
// top-right, bottom-left, bottom-right for the axis square, whose marks sit on
// the diagonals; top, left, right, bottom for the diagonal square, whose marks
// sit on the axes); a rhomb's is its pair of edge directions. Both generators
// carry all twelve, which is what lets the swap at the wall keep every tile in
// place.
const AXIS_MARKS = [3, 1, 5, 7];
const DIAG_MARKS = [2, 4, 0, 6];
const RHOMB_SLOTS = ['0,1', '1,2', '2,3', '0,3'];
function slotOf(protoPoly, markPt) {
const key = pairKey(protoPoly);
if (key === '0,2' || key === '1,3') {
if (!markPt) throw new Error('octagonalSubstitution: unmarked square');
const c = centroid(protoPoly);
const m = markIdx([markPt[0] - c[0], markPt[1] - c[1]]);
const i = (key === '0,2' ? AXIS_MARKS : DIAG_MARKS).indexOf(m);
if (i < 0) throw new Error(`octagonalSubstitution: square mark at index ${m}`);
return (key === '0,2' ? 0 : 4) + i;
}
const i = RHOMB_SLOTS.indexOf(key);
if (i < 0) throw new Error(`octagonalSubstitution: rhomb with edge pair ${key}`);
return 8 + i;
}
// The orientation dot: 30% in from the marked corner toward the centre, radius
// from the tile's mean corner distance (both source figures' convention).
function dotAt(poly, corner) {
const c = centroid(poly);
const hd = poly.reduce((s, p) => s + Math.hypot(p[0] - c[0], p[1] - c[1]), 0) / poly.length;
return [[corner[0] + 0.3 * (c[0] - corner[0]), corner[1] + 0.3 * (c[1] - corner[1])], 0.17 * hd];
}
// shearSubstitution reports a mark as a sector direction in the window; the dot
// goes on the corner lying that way. flexibleAB gives the corner itself.
function cornerInDir(poly, dir) {
const c = centroid(poly);
let best = poly[0], bs = -Infinity;
for (const p of poly) {
const v = [p[0] - c[0], p[1] - c[1]], n = Math.hypot(v[0], v[1]) || 1;
const s = (v[0] * dir[0] + v[1] * dir[1]) / n;
if (s > bs) { bs = s; best = p; }
}
return best;
}
// ── The silver inflation regime, rho >= 1/sqrt2 ──────────────────────────────
// flexibleAB's PANELS carry the rule as lattice data: for each of the twelve
// marked oriented tiles, the prototile, its inflated outline, the owned
// children with their marks, the anchor offsets that place the window copies,
// and the lattice offsets that cut its window region. All of it is
// ratio-independent; only the projection carries rho.
const planeMap = (r) => (n) => [n[0] + (r / SQ2) * (n[1] - n[3]), n[2] + (r / SQ2) * (n[1] + n[3])];
// The internal (window) star is the Galois conjugate rho -> -rho, in uv
// coordinates u = (n0, n2), v = (n1 - n3, n1 + n3). Taken in the BASE tiling's
// own frame, so the outer octagon is the base window -- the same object the
// shear figure draws, which is what carries it continuously across the wall.
// (flexibleAB works in the inflated tiling's coarse frame instead, where the
// internal ratio is 1/sigma; the region combinatorics are the same either way,
// since they are cut by lattice offsets under one common projection.)
const winMap = (SW) => (n) => {
const w = AB.uv4(n);
return [w[0] - SW * w[2], w[1] - SW * w[3]];
};
function sceneSilver(rho) {
const k = 1 / (1 + SQ2 * rho);
const L0 = planeMap(rho);
const L = (n) => csc(L0(n), k);
const wp = winMap(rho / SQ2);
const OFP = AB.OCT_F.map(wp); // window of the base tiling
const OBP = AB.OCT_B.map(wp); // window of the inflated tiling: the subwindow
const classes = new Array(12).fill(null);
for (const P of AB.PANELS) {
const proto = P.proto.poly.map(L);
const protoMark = P.proto.mark ? L(P.proto.mark) : null;
const drawn = P.pieces.filter((pc) => pc.owned);
// The window region of this class: the part of the subwindow whose points
// admit this supertile (its own corners, against subwindow translates) with
// exactly these children (their corners, against window translates).
let R = OBP;
for (const r of P.relInf) {
const t = wp(r);
R = convexClip(R, OBP.map((p) => [p[0] - t[0], p[1] - t[1]]));
}
for (const o of P.clipOffs) {
const t = wp(o);
R = convexClip(R, OFP.map((p) => [p[0] - t[0], p[1] - t[1]]));
}
classes[slotOf(proto, protoMark)] = {
proto,
protoDot: protoMark ? dotAt(proto, protoMark) : null,
colour: colourOf(proto),
outline: P.outline.map(L),
kids: drawn.map((pc) => {
const poly = pc.poly.map(L);
return {
poly,
colour: colourOf(poly),
dot: pc.mark ? dotAt(poly, L(pc.mark)) : null,
copyOff: wp(pc.off),
};
}),
cell: dedupe(R),
cellOff: [0, 0],
};
}
return { k, classes, outer: OFP, inner: OBP };
}
// ── The parabolic shear regime, rho < 1/sqrt2 ────────────────────────────────
// shearSubstitution enumerates its rule afresh at every ratio and hands back
// the whole scene; only the framing changes here. Its panels lead with the
// SUPERTILE, this figure with the base tile of the same type at the current
// ratio (the tile whose image the rule describes), so that the left column is
// the same object on both sides of the wall.
function sceneShear(rho) {
const SC = SH.scene(Math.min(rho, SH.RHO_MAX));
const z = SC.S.z;
const classes = new Array(12).fill(null);
for (const cl of SC.classes) {
if (!cl) continue;
const [i, j] = SH.PAIRS[cl.pi];
const proto = [[0, 0], z[i], cadd(z[i], z[j]), z[j]].map((p) => csc(p, SC.k));
const protoMark = cl.mark ? cornerInDir(proto, cl.mark) : null;
classes[slotOf(proto, protoMark)] = {
proto,
protoDot: protoMark ? dotAt(proto, protoMark) : null,
colour: colourOf(proto),
outline: SC.quads[cl.pi],
kids: cl.kids.map((e, ki) => {
const poly = SC.childQuad(e);
const mk = cl.kidMark[ki];
return {
poly,
colour: colourOf(poly),
dot: mk ? dotAt(poly, cornerInDir(poly, mk)) : null,
copyOff: SC.av(e),
};
}),
// shearSubstitution also carries the base tiles the supertile cuts
// through but does not own; only owned children are drawn here, as in the
// inflation panels, so the two regimes read alike.
cell: cl.cell,
cellOff: cl.off2,
};
}
return { k: SC.k, classes, outer: SC.W, inner: SC.W2 };
}
// The wall is degenerate for BOTH rules, and for the same reason: the internal
// axis generator of either image lattice is 1 - sqrt2 rho, which vanishes at
// rho = 1/sqrt2. There each subwindow has collapsed to the same bare diagonal
// square of area 1/2 -- the shear's rule breaks down past it, and on the silver
// side the four rhomb classes' cells have shrunk to nothing. Both regimes are
// held a slider half-step clear of it (shearSubstitution clamps itself from
// below; the same guard applies here from above), which keeps the clipping away
// from the collapse without any visible loss of range.
const WALL_GUARD = 1e-3;
const scene = (rho) => (rho >= WALL
? sceneSilver(Math.max(rho, WALL + WALL_GUARD))
: sceneShear(rho));
// ── Fixed layout (figure coordinates, y up) ──────────────────────────────────
// One slot per class, sized on the maximum extents over the WHOLE band, both
// regimes together, so nothing jumps as the ratio moves or as the rule swaps.
const COLS = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]];
const LAYOUT = (() => {
// The shear side costs a lattice sweep per sample, so it is sampled coarsely;
// extents vary smoothly and are dominated by the band ends.
const rhos = [RHO_MIN, 0.2, 0.35, 0.5, 0.62, 0.7,
WALL + 1e-3, 0.75, 0.8, 0.85, 0.9, 0.95, RHO_MAX];
const ext = Array.from({ length: 12 }, () => ({ pw: 0, ph: 0, dw: 0, dh: 0 }));
let winR = 0;
for (const r of rhos) {
const SC = scene(r);
for (const p of SC.outer) winR = Math.max(winR, Math.hypot(p[0], p[1]));
SC.classes.forEach((cl, gi) => {
if (!cl) return;
const pb = bboxOf(cl.proto);
ext[gi].pw = Math.max(ext[gi].pw, pb.w);
ext[gi].ph = Math.max(ext[gi].ph, pb.h);
// The image outline stays centred in its slot and the children poke out
// around it, so the slot is sized symmetrically about that centre.
const ob = bboxOf(cl.outline);
const db = bboxOf(cl.outline.concat(...cl.kids.map((kd) => kd.poly)));
ext[gi].dw = Math.max(ext[gi].dw, 2 * Math.max(ob.cx - db.x0, db.x1 - ob.cx));
ext[gi].dh = Math.max(ext[gi].dh, 2 * Math.max(ob.cy - db.y0, db.y1 - ob.cy));
});
}
const gap = 0.28, arrowLen = 0.5, rowGap = 0.42, colGap = 0.9;
const protoSlot = COLS.map((c) => Math.max(...c.map((gi) => ext[gi].pw)));
const decompSlot = COLS.map((c) => Math.max(...c.map((gi) => ext[gi].dw)));
const rowH = [0, 1, 2, 3].map((r) =>
Math.max(...COLS.map((c) => Math.max(ext[c[r]].ph, ext[c[r]].dh))));
const slots = new Array(12);
let colX = 0;
COLS.forEach((col, kk) => {
const ax1 = colX + protoSlot[kk] + gap;
const ax2 = ax1 + arrowLen;
let rowY = 0;
col.forEach((gi, r) => {
const cy = rowY - rowH[r] / 2;
slots[gi] = {
protoC: [colX + protoSlot[kk] / 2, cy],
decompC: [ax2 + gap + decompSlot[kk] / 2, cy],
ax1, ax2, ay: cy,
halfH: Math.max(ext[gi].ph, ext[gi].dh) / 2 + 0.45 * rowGap,
x0: colX - 0.2, x1: ax2 + gap + decompSlot[kk] + 0.2,
};
rowY -= rowH[r] + rowGap;
});
colX += protoSlot[kk] + gap + arrowLen + gap + decompSlot[kk] + colGap;
});
const gridW = colX - colGap;
const gridH = rowH.reduce((a, b) => a + b, 0) + 3 * rowGap;
// One window on the right: the base window with the image window nested
// centrally, both centred zonogons about 0 in one internal plane at one
// fixed scale, so the octagon really does grow with the ratio.
const winSlotR = 0.4 * gridH;
const WSC = winSlotR / winR;
const winX = gridW + 0.7 + winSlotR;
const wY = -0.5 * gridH;
const bounds = { x0: 0, y0: -gridH - 0.02 * gridH, x1: winX + winSlotR, y1: 0 };
return { slots, gridW, gridH, WSC, winX, wY, winSlotR, bounds };
})();
// ── Resident CindyJS scene serialization ─────────────────────────────────────
const round6 = (x) => Math.round(x * 1e6) / 1e6;
const ser = (v) => JSON.stringify(v, (_k, val) => (typeof val === 'number' ? round6(val) : val));
const UCPTS = Array.from({ length: 20 }, (_, k) =>
[Math.cos((k * Math.PI) / 10), Math.sin((k * Math.PI) / 10)]);
// Static arrows between the panel columns.
const ARROWS = LAYOUT.slots.map(({ ax1, ax2, ay }) => {
const hl = 0.2, hw = 0.11, th = 0.038;
return [
[[ax1, ay - th], [ax2 - hl, ay - th], [ax2 - hl, ay + th], [ax1, ay + th]],
[[ax2, ay], [ax2 - hl, ay + hw], [ax2 - hl, ay - hw]],
];
});
// Per-ratio push: panel polygons in figure coordinates, window data in internal
// coordinates.
// CLS entry (one per class, [] when a class is absent):
// 1 protoPolyFig 2 imageOutlineFig
// 3 kids([quadFig, colourIdx, copyOffInt, dot([centreFig, rFig] or [])])
// 4 hitboxFig 5 cellInt 6 cellOffInt
// 7 protoDot([centreFig, rFig] or []) 8 ownColourIdx
// Dots are drawn on the prototile and on marked children, never on the image
// outline, so a dot always belongs to the tile it sits in.
// WIN: [subwindowInt, windowInt]
function pushString(SC) {
const cls = SC.classes.map((cl, gi) => {
if (!cl) return [];
const slot = LAYOUT.slots[gi];
const pb = bboxOf(cl.proto), ob = bboxOf(cl.outline);
const atP = (p) => [slot.protoC[0] + p[0] - pb.cx, slot.protoC[1] + p[1] - pb.cy];
const atD = (p) => [slot.decompC[0] + p[0] - ob.cx, slot.decompC[1] + p[1] - ob.cy];
const mapDot = (d, at) => (d ? [at(d[0]), d[1]] : []);
const hit = [[slot.x0, slot.ay - slot.halfH], [slot.x1, slot.ay - slot.halfH],
[slot.x1, slot.ay + slot.halfH], [slot.x0, slot.ay + slot.halfH]];
return [
cl.proto.map(atP),
cl.outline.map(atD),
cl.kids.map((kd) => [kd.poly.map(atD), kd.colour, kd.copyOff, mapDot(kd.dot, atD)]),
hit, cl.cell, cl.cellOff, mapDot(cl.protoDot, atP), cl.colour,
];
});
return 'CLS=' + ser(cls) + ';WIN=' + ser([SC.inner, SC.outer]) + ';';
}
// Helper functions living in Cindy. dc dims toward the page background; tcol
// applies hover dimming per class; mp maps figure to view space; wm maps
// internal coordinates into the window slot (window and subwindow share the
// plane, so they share the map); ptin tests a point against a convex polygon of
// either orientation; mrk draws an orientation dot; hitat scans HITL, the
// [kind, class, key, poly] hit targets recorded by the last draw pass, in
// priority order.
const FN_DEFS =
'dc(c):=c*0.25+PBG*0.75;' +
'tcol(c,g):=if((HLC>0)&(g!=HLC),dc(c),c);' +
'mp(p):=[MPX+MSC*p_1,MPY+MSC*p_2];' +
'wm(p):=mp([WX+WSC2*p_1,WY+WSC2*p_2]);' +
'ptin(pl,q):=(regional(sg,ok,aa,bb,crr,s2);sg=0;ok=true;' +
'forall(1..length(pl),ii,(aa=pl_ii;bb=pl_(mod(ii,length(pl))+1);' +
'crr=(bb_1-aa_1)*(q_2-aa_2)-(bb_2-aa_2)*(q_1-aa_1);' +
'if(abs(crr)>0.000000000001,(s2=if(crr>0,1,-1);' +
'if(sg==0,sg=s2,if(s2!=sg,ok=false))))));ok);' +
'mrk(dd,g):=(regional(cv,rr);cv=dd_1;rr=dd_2;' +
'fillpoly(apply(UC,u,mp(cv+rr*u)),color->tcol(DOT,g),alpha->1);' +
'drawpoly(apply(UC,u,mp(cv+rr*u)),color->tcol(INK,g),size->LW*0.5));' +
'hitat(hx,hy):=(regional(res);res=[-1,-1,-1];' +
'forall(HITL,h,if(res_1<0,if(ptin(h_4,[hx,hy]),res=[h_1,h_2,h_3])));res);';
// The draw pass. Everything comes from CLS/WIN (pushed when the slider moves)
// and two interaction scalars: HLC, the hovered class (1..12, -1 for none,
// everything else dimming), and HLK, the hovered child within it (-1 for none,
// when the whole class's copies light up instead). Alongside drawing it
// rebuilds HITL, the hit targets, in priority order: panel child tiles,
// subwindow cells, window copies, panel boxes.
const DRAW =
'HA=[];HB=[];HC=[];HD=[];' +
// panels: prototile, arrow, owned children, image outline, dots
'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
'ppv=apply(cl_1,p,mp(p));' +
'fillpoly(ppv,color->tcol(TC_(cl_8),g),alpha->1);' +
'drawpoly(ppv,color->tcol(INK,g),size->LW*1.1);' +
'if(length(cl_7)>0,mrk(cl_7,g));' +
'forall(ARR_g,ap,fillpoly(apply(ap,p,mp(p)),color->tcol(INK,g),alpha->1));' +
'forall(1..length(cl_3),ki,(kd=(cl_3)_ki;wp=apply(kd_1,p,mp(p));' +
'HA=HA++[[1,g,ki,wp]];' +
'fillpoly(wp,color->if((g==HLC)&(ki==HLK),SEL,tcol(TC_(kd_2),g)),alpha->1);' +
'drawpoly(wp,color->tcol(INK,g),size->LW*0.9)));' +
'drawpoly(apply(cl_2,p,mp(p)),color->if(g==HLC,SEL,tcol(INK,g)),size->LW*1.6);' +
'forall(cl_3,kd,if(length(kd_4)>0,mrk(kd_4,g)));' +
'HD=HD++[[0,g,-1,apply(cl_4,p,mp(p))]]' +
'))));' +
// the window: each class cell translated by each of its children's anchor
// offsets. These copies tile the whole window: the action of the rule. Each
// is filled with the colour of the tile it substitutes FROM, so the window
// reads as the rule's action rather than as an inventory of the base tiles.
'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
'forall(1..length(cl_3),ki,(kd=(cl_3)_ki;' +
'cpv=apply(cl_5,p,wm(p+kd_3));' +
'HC=HC++[[2,g,ki,cpv]];' +
'fillpoly(cpv,color->if((g==HLC)&((HLK<0)%(ki==HLK)),SEL,tcol(TC_(cl_8),g)),alpha->1);' +
'drawpoly(cpv,color->tcol(INK,g),alpha->0.6,size->LWW*0.5)))' +
'))));' +
// the subwindow: the image window nested centrally, one blue cell per class
'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
'cpv=apply(cl_5,p,wm(p+cl_6));' +
'HB=HB++[[3,g,-1,cpv]];' +
'fillpoly(cpv,color->if(g==HLC,SEL*0.5+TB_(cl_8)*0.5,tcol(TB_(cl_8),g)),alpha->1);' +
'drawpoly(cpv,color->INK,alpha->0.8,size->LWW*0.6)' +
'))));' +
'drawpoly(apply(WIN_1,p,wm(p)),color->INK,size->LWW*1.2);' +
'drawpoly(apply(WIN_2,p,wm(p)),color->INK,size->LWW*1.8);' +
'HITL=HA++HB++HC++HD;';
// ── mount contract ───────────────────────────────────────────────────────────
const FIT_M = 2;
const ASPECT = (() => {
const B = LAYOUT.bounds;
return 100 / ((100 - 2 * FIT_M) * (B.y1 - B.y0) / (B.x1 - B.x0) + 2 * FIT_M);
})();
let uid = 0;
export async function mount(el, opts = {}) {
await loadCindy();
el.style.height = 'auto';
el.style.aspectRatio = String(ASPECT);
el.style.position = 'relative';
el.style.background = '#fff';
let rho = RHO_DEF;
let cdy = null;
let view = { VW: 100, VH: 75 };
const id = 'octSub_' + (++uid);
const clampRho = (r) => Math.min(RHO_MAX, Math.max(RHO_MIN, +r));
function pushScene() {
if (!cdy) return;
try { cdy.evokeCS(pushString(scene(rho))); } catch (e) { console.error('octagonalSubstitution push:', e); }
}
function makeMount() {
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
el.appendChild(div);
if (opts.poster) return;
// Pointer flow: view coordinates through evalcs (no repaint unless the
// interaction state changed). Hovering a panel or a subwindow cell selects
// the class; hovering one child tile or one window copy selects the pairing
// inside it. The returned kind styles the cursor.
div.addEventListener('pointermove', (ev) => {
if (!cdy) return;
const r = div.getBoundingClientRect();
if (!r.width || !r.height) return;
const vx = ((ev.clientX - r.left) / r.width) * view.VW;
const vy = (1 - (ev.clientY - r.top) / r.height) * view.VH;
try {
const res = cdy.evalcs(
'hh=hitat(' + vx + ',' + vy + ');ch=0;' +
'if(hh_1>=0,(' +
'if(HLC!=hh_2,(HLC=hh_2;ch=1));' +
'if(HLK!=hh_3,(HLK=hh_3;ch=1))' +
'),(' +
'if(HLC>0,(HLC=-1;ch=1));' +
'if(HLK>=0,(HLK=-1;ch=1))' +
'));' +
'if(ch>0,repaint());hh_1');
const kind = res && res.value && typeof res.value.real === 'number' ? res.value.real : -1;
div.style.cursor = kind >= 0 ? 'pointer' : '';
} catch (e) { console.error('octagonalSubstitution hover:', e); }
});
div.addEventListener('pointerleave', () => {
if (!cdy) return;
div.style.cursor = '';
try {
cdy.evalcs('if((HLC>0)%(HLK>=0),(HLC=-1;HLK=-1;repaint()));');
} catch (e) { console.error('octagonalSubstitution hover:', e); }
});
}
function sizeNow() {
const r = el.getBoundingClientRect();
return [Math.max(1, Math.round(r.width)), Math.max(1, Math.round(r.height))];
}
function create() {
const [W, H] = sizeNow();
makeMount();
view = { VW: 100, VH: (100 * H) / W };
const B = LAYOUT.bounds;
const bw = B.x1 - B.x0, bh = B.y1 - B.y0, m = FIT_M;
const sc = Math.min((view.VW - 2 * m) / bw, (view.VH - 2 * m) / bh);
const exX = (view.VW - 2 * m) - bw * sc, exY = (view.VH - 2 * m) - bh * sc;
const pxPerView = W / view.VW; // stroke widths are in px
const DATA =
'PBG=' + ser(pageBg) + ';SEL=' + ser(selCol) + ';INK=' + ser(ink) + ';' +
'DOT=' + ser(dotFill) + ';' +
'TC=' + ser(TYPECOL) + ';TB=' + ser(TYPEBLUE) + ';UC=' + ser(UCPTS) + ';' +
'ARR=' + ser(ARROWS) + ';NC=12;' +
'WX=' + round6(LAYOUT.winX) + ';WY=' + round6(LAYOUT.wY) +
';WSC2=' + round6(LAYOUT.WSC) + ';' +
'MPX=' + (m + exX / 2 - B.x0 * sc) + ';MPY=' + (m + exY / 2 - B.y0 * sc) +
';MSC=' + sc + ';' +
'LW=' + round6(Math.max(0.9, 0.02 * sc * pxPerView)) + ';' +
'LWW=' + round6(Math.max(0.8, 0.016 * sc * pxPerView)) + ';' +
'HLC=-1;HLK=-1;HITL=[];' +
FN_DEFS +
pushString(scene(rho));
cdy = window.CindyJS({
scripts: { init: DATA, draw: DRAW },
ports: [{
id,
width: W,
height: H,
transform: [{ visibleRect: [0, 0, view.VW, view.VH] }],
}],
});
// CindyJS defers its setup to window.onload; we mount lazily, so kick
// startup ourselves (guarded: startup before load would double-init).
if (cdy && typeof cdy.startup === 'function' && document.readyState !== 'loading') {
try { cdy.startup(); } catch (e) { console.error('octagonalSubstitution startup:', e); }
}
}
create();
// Recreate on resize (CindyJS ports are fixed-size); debounced, skip no-ops.
let [lastW, lastH] = sizeNow();
let rt = null;
const ro = new ResizeObserver(() => {
clearTimeout(rt);
rt = setTimeout(() => {
const [W, H] = sizeNow();
if (W === lastW && H === lastH) return;
lastW = W; lastH = H;
try { cdy && cdy.shutdown && cdy.shutdown(); } catch (e) { /* ignore */ }
el.innerHTML = '';
create();
}, 180);
});
ro.observe(el);
return {
setUniform(name, value) {
if (name === 'uRho') { rho = clampRho(value); pushScene(); }
},
getUniform(name) { return name === 'uRho' ? rho : undefined; },
pause() { /* static image: nothing to pause */ },
resume() { },
reset() {
rho = RHO_DEF;
if (cdy) {
try { cdy.evokeCS('HLC=-1;HLK=-1;'); } catch (e) { /* ignore */ }
}
pushScene();
},
get isPaused() { return false; },
destroy() {
try { ro.disconnect(); } catch (e) { /* ignore */ }
clearTimeout(rt);
try { cdy && cdy.shutdown && cdy.shutdown(); } catch (e) { /* ignore */ }
el.innerHTML = '';
},
};
}
// Pure internals exposed for the regression tests; the mount contract above is
// the only consumer-facing export.
export const _test = { scene, sceneSilver, sceneShear, pushString, slotOf, colourOf,
LAYOUT, WALL, RHO_MIN, RHO_MAX, RHO_DEF, DRAW, FN_DEFS };