// derived from https://cs.uwaterloo.ca/~csk/spectre/spectre.js const {PI, cos, sin} = Math; function radians(degrees) { return degrees * PI / 180; } let num_pieces = 0; class Point { constructor(x,y) { this.x = x; this.y = y; } add(q) { return { x : this.x + q.x, y : this.y + q.y }; } sub(q) { return { x : this.x - q.x, y : this.y - q.y }; } frame(p, q, a, b) { return{ x : this.x + a*p.x + b*q.x, y : this.y + a*p.y + b*q.y }; } } class Matrix { constructor(mat) { this.mat = mat; } determinant() { const T = this.mat; const det = T[0]*T[4] - T[1]*T[3]; } inverse() { const T = this.mat; const det = this.determinant(); return new Matrix([ T[4]/det, -T[1]/det, (T[1]*T[5]-T[2]*T[4])/det, -T[3]/det, T[0]/det, (T[2]*T[3]-T[0]*T[5])/det ]); } mul(other) { const A = this.mat; const B = other.mat; return new Matrix([ A[0]*B[0] + A[1]*B[3], A[0]*B[1] + A[1]*B[4], A[0]*B[2] + A[1]*B[5] + A[2], A[3]*B[0] + A[4]*B[3], A[3]*B[1] + A[4]*B[4], A[3]*B[2] + A[4]*B[5] + A[5] ]); } // Rotation matrix static rotation(ang) { const c = cos(ang); const s = sin(ang); return new Matrix([c, -s, 0, s, c, 0]); } static scale(x,y) { return new Matrix([x,0,0,0,y,0]); } static translate(tx, ty) { return new Matrix([1, 0, tx, 0, 1, ty]); } // Translation matrix moving p to q static translateTo(p, q) { return Matrix.translate(q.x - p.x, q.y - p.y); } transform(P) { const M = this.mat; return new Point(M[0]*P.x + M[1]*P.y + M[2], M[3]*P.x + M[4]*P.y + M[5]); } } const ident = new Matrix([1,0,0,0,1,0]); const spectre = [ new Point(0, 0), new Point(1.0, 0.0), new Point(1.5, -0.8660254037844386), new Point(2.366025403784439, -0.36602540378443865), new Point(2.366025403784439, 0.6339745962155614), new Point(3.366025403784439, 0.6339745962155614), new Point(3.866025403784439, 1.5), new Point(3.0, 2.0), new Point(2.133974596215561, 1.5), new Point(1.6339745962155614, 2.3660254037844393), new Point(0.6339745962155614, 2.3660254037844393), new Point(-0.3660254037844386, 2.3660254037844393), new Point(-0.866025403784439, 1.5), new Point(0.0, 1.0) ]; const base_quad = [spectre[3], spectre[5], spectre[7], spectre[11]]; function getsvg(event) { let t = event.target; while(t && t.tagName.toLowerCase()!='svg') { t = t.parentElement; } return t; } function getcoords(event) { const t = getsvg(event); if(!t) { return; } const point = t.createSVGPoint() point.x = event.clientX point.y = event.clientY const position = point.matrixTransform(t.getScreenCTM().inverse()) return position; } class Tile { constructor(pts, quad) { this.pts = pts; this.quad = quad; this.pts = [pts[pts.length-1]]; for(const p of pts) { const prev = this.pts[this.pts.length-1]; const v = p.sub(prev); const w = new Point(-v.y, v.x); this.pts.push(prev.frame(v, w, 0.5, -0.3)); this.pts.push(prev.frame(v, w, 0.5, 0.3)); this.pts.push(p); } } streamSVG(S, stream) { const tpts = this.pts.map(p => S.transform(p)); const [a,c,e,b,d,f] = S.mat; const matS = [a,b,c,d,e,f].map(p=>p.toFixed(3)); num_pieces += 1; stream.push(` ${num_pieces} `); } } class Metatile { constructor() { this.geoms = []; this.quad = []; } addChild(g, T) { this.geoms.push({ geom : g, xform: T }); } streamSVG(S, stream) { for(let g of this.geoms) { g.geom.streamSVG(S.mul(g.xform), stream); } } } function tiles(level, label) { let quad; let out = []; let transform; if(level == 0) { transform = ident; quad = base_quad; switch(label) { case 'Delta': case 'Theta': case 'Lambda': case 'Xi': case 'Pi': case 'Sigma': case 'Phi': case 'Psi': out.push(ident); case 'Gamma': const mystic = new Metatile(); out.push(ident); out.push(Matrix.translate(spectre[8].x, spectre[8].y).mul(Matrix.rotation(PI / 6))); } } else { /* * Each of the subtiles is identical, but rotated and translated. * * Produce transformation matrices Ts for each of the subtiles: they're formed by rotating the quad and then matching up a pair of points. * * The whole thing is then reflected. * * The layout of subtiles depends on the larger tile. * */ const labels = [ 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Phi', 'Psi' ]; const sublevels = Object.fromEntries(labels.map(label => tiles(level-1, label))); const subquad = sublevels['Delta'].quad; const reflection = Matrix.scale(-1,1); // How to get from each subtile to the next. const t_rules = [ [60, 3, 1], [0, 2, 0], [60, 3, 1], [60, 3, 1], [0, 2, 0], [60, 3, 1], [-120, 3, 3] ]; let Ts = [ident]; let total_ang = 0; let rot = ident; let tquad = [...subquad]; for(const [ang,from,to] of t_rules) { total_ang += ang; if(ang != 0) { rot = Matrix.rotation(radians(total_ang)); tquad = subquad.map(q => rot.transform(q)); } const ttt = Matrix.translateTo(tquad[to], Ts[Ts.length-1].transform(subquad[from])); Ts.push(ttt.mul(rot)); } Ts = Ts.map(t => mul(reflection, t)); // Now build the actual supertiles, labelling appropriately. const super_rules = { 'Gamma' : ['Pi','Delta','null','Theta','Sigma','Xi','Phi','Gamma'], 'Delta' : ['Xi','Delta','Xi','Phi','Sigma','Pi','Phi','Gamma'], 'Theta' : ['Psi','Delta','Pi','Phi','Sigma','Pi','Phi','Gamma'], 'Lambda' : ['Psi','Delta','Xi','Phi','Sigma','Pi','Phi','Gamma'], 'Xi' : ['Psi','Delta','Pi','Phi','Sigma','Psi','Phi','Gamma'], 'Pi' : ['Psi','Delta','Xi','Phi','Sigma','Psi','Phi','Gamma'], 'Sigma' : ['Xi','Delta','Xi','Phi','Sigma','Pi','Lambda','Gamma'], 'Phi' : ['Psi','Delta','Psi','Phi','Sigma','Pi','Phi','Gamma'], 'Psi' : ['Psi','Delta','Psi','Phi','Sigma','Psi','Phi','Gamma'] }; const super_quad = [ Ts[6].transform(subquad[2]), Ts[5].transform(subquad[1]), Ts[3].transform(subquad[2]), Ts[0].transform(subquad[1]) ]; } return {quad, tiles: out}; } let last_num_iterations; function get_settings() { return Object.fromEntries( Array.from(document.querySelectorAll('input,textarea')).map(i => [i.id, i.type=='number' ? i.valueAsNumber : i.value]) ); } class Builder { constructor(settings) { this.settings = settings; } build() { const {settings} = this; num_pieces = 0; let sys = this.buildSpectreBase(); for(let i=0;i { if(!last_click) { return; } let tile = e.target; while(tile && !tile.classList.contains('tile')) { tile = tile.parentElement; } if(!tile) { return; } for(let el of svg.querySelectorAll('.highlight')) { el.classList.remove('highlight'); } tile.classList.add('highlight'); }); } } function update_display() { const settings = get_settings(); const svg = document.querySelector('svg'); svg.setAttribute('viewBox',`${-settings.scale/2} ${-settings.scale/2} ${settings.scale} ${settings.scale}`); const mx = spectre.map(p=>p.x).reduce((a,b)=>a+b)/spectre.length; const my = spectre.map(p=>p.y).reduce((a,b)=>a+b)/spectre.length; document.getElementById('spectre').setAttribute('transform',`translate(${mx},${my}) translate(${-mx},${-my})`); if(settings.num_iterations != last_num_iterations) { const builder = new Builder(settings); builder.build(); builder.draw(); last_num_iterations = settings.num_iterations; } } for(let i of document.querySelectorAll('input')) { i.addEventListener('input', update_display); i.addEventListener('change', update_display) } update_display(); let opos; let dragging; let pan = {x:0, y:0}; let npan = pan; let last_click = false; const svg = document.querySelector('svg'); svg.addEventListener('pointerdown', e => { opos = getcoords(e); dragging = true; }); svg.addEventListener('pointermove', e => { if(!dragging) { return; } const pos = getcoords(e); npan = {x: pan.x + pos.x - opos.x, y: pan.y + pos.y - opos.y}; document.getElementById('board').setAttribute('transform', `translate(${npan.x} ${npan.y})`); }); svg.addEventListener('pointerup', e => { dragging = false; const [dx,dy] = [npan.x - pan.x, npan.y - pan.y]; const d = Math.sqrt(dx*dx + dy*dy); last_click = d < 0.5; pan = npan; });