diff --git a/public/boardRender.js b/public/boardRender.js index 6c63b3f..bb72e0e 100644 --- a/public/boardRender.js +++ b/public/boardRender.js @@ -4,6 +4,12 @@ * lane = branch offset that fans out at a 'choice' space and re-centers * (averages) at whichever space its branches rejoin. This means the layout * keeps working unmodified as shared/board.js grows. + * + * Visual style is illustrated-map-ish rather than flowchart-ish: a + * procedurally scattered terrain backdrop (river, mountains, trees) that + * doesn't need to align to any specific tile, a wide dashed "road" between + * spaces, and tiles drawn as beveled signs with a small deterministic tilt + * per space so they read as hand-placed rather than machine-stamped. */ const SVG_NS = 'http://www.w3.org/2000/svg'; @@ -109,6 +115,18 @@ function escapeHtml(str) { )); } +/** Small stable hash so per-tile tilt/detail is deterministic, not random-per-render. */ +function hashString(str) { + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0; + return Math.abs(h); +} + +function makeRand(seed) { + let s = seed; + return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; +} + export class BoardView { constructor(container, board) { this.board = board; @@ -126,6 +144,7 @@ export class BoardView { role: 'img', 'aria-label': 'Life Journey board', }); + svg.appendChild(this._buildDefs()); svg.appendChild(this._buildBackdrop(width, height)); svg.appendChild(this._buildEdges()); svg.appendChild(this._buildNodes()); @@ -137,15 +156,102 @@ export class BoardView { this.container.appendChild(svg); } + _buildDefs() { + const defs = svgEl('defs'); + + const field = svgEl('radialGradient', { id: 'fieldGradient', cx: '30%', cy: '0%', r: '85%' }); + field.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#2a5f4c' })); + field.appendChild(svgEl('stop', { offset: '55%', 'stop-color': '#1d493c' })); + field.appendChild(svgEl('stop', { offset: '100%', 'stop-color': '#163a30' })); + defs.appendChild(field); + + const gloss = svgEl('linearGradient', { id: 'tileGloss', x1: '0', y1: '0', x2: '0', y2: '1' }); + gloss.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#ffffff', 'stop-opacity': '0.38' })); + gloss.appendChild(svgEl('stop', { offset: '60%', 'stop-color': '#ffffff', 'stop-opacity': '0' })); + defs.appendChild(gloss); + + // Subtle hand-drawn wobble applied to the road paths only. + const wobble = svgEl('filter', { id: 'roadWobble', x: '-20%', y: '-20%', width: '140%', height: '140%' }); + wobble.appendChild(svgEl('feTurbulence', { + type: 'fractalNoise', baseFrequency: '0.012 0.02', numOctaves: '2', seed: '3', result: 'noise', + })); + wobble.appendChild(svgEl('feDisplacementMap', { + in: 'SourceGraphic', in2: 'noise', scale: '5', xChannelSelector: 'R', yChannelSelector: 'G', + })); + defs.appendChild(wobble); + + return defs; + } + _buildBackdrop(width, height) { const g = svgEl('g', { class: 'backdrop' }); - let seed = 7; // deterministic — stable across re-renders, not flickering - const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; - const count = Math.max(6, Math.floor((width * height) / 11000)); - for (let i = 0; i < count; i++) { - g.appendChild(svgEl('circle', { - cx: rand() * width, cy: rand() * height, r: 16 + rand() * 26, class: 'tree-blob', + g.appendChild(svgEl('rect', { x: 0, y: 0, width, height, class: 'field-bg' })); + + const rand = makeRand(7); // deterministic — stable across re-renders, not flickering + + const mountainCount = Math.max(3, Math.floor(width / 460)); + for (let i = 0; i < mountainCount; i++) { + g.appendChild(this._buildMountainCluster(rand() * width, rand() * height, 70 + rand() * 55, rand)); + } + + g.appendChild(this._buildRiver(width, height, rand)); + + const treeClusterCount = Math.max(14, Math.floor((width * height) / 15000)); + for (let i = 0; i < treeClusterCount; i++) { + g.appendChild(this._buildTreeCluster(rand() * width, rand() * height, rand)); + } + + return g; + } + + _buildMountainCluster(cx, cy, size, rand) { + const g = svgEl('g', { class: 'mountain-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` }); + const peaks = 3; + for (let i = 0; i < peaks; i++) { + const w = size * (0.55 + rand() * 0.3); + const h = size * (0.75 + rand() * 0.25); + const x = (i - 1) * size * 0.5; + g.appendChild(svgEl('polygon', { + points: `${x},0 ${x - w / 2},${h} ${x + w / 2},${h}`, class: 'mountain-body', })); + g.appendChild(svgEl('polygon', { + points: `${x},0 ${x - w * 0.18},${h * 0.3} ${x + w * 0.18},${h * 0.3}`, class: 'mountain-snow', + })); + } + return g; + } + + _buildRiver(width, height, rand) { + const steps = Math.max(4, Math.round(height / 260)); + const points = []; + for (let i = 0; i <= steps; i++) { + points.push([width * 0.15 + rand() * width * 0.7, (height / steps) * i]); + } + let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`; + for (let i = 1; i < points.length; i++) { + const [px, py] = points[i - 1]; + const [x, y] = points[i]; + const cy = (py + y) / 2; + d += ` C ${px.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${y.toFixed(1)}`; + } + const g = svgEl('g', { class: 'river' }); + g.appendChild(svgEl('path', { d, class: 'river-body' })); + g.appendChild(svgEl('path', { d, class: 'river-shine' })); + return g; + } + + _buildTreeCluster(cx, cy, rand) { + const g = svgEl('g', { class: 'tree-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` }); + const count = 1 + Math.floor(rand() * 2); + for (let i = 0; i < count; i++) { + const ox = (rand() - 0.5) * 20; + const oy = (rand() - 0.5) * 12; + const r = 10 + rand() * 9; + g.appendChild(svgEl('circle', { + cx: ox.toFixed(1), cy: (oy - r * 0.6).toFixed(1), r: r.toFixed(1), + class: rand() > 0.5 ? 'tree-canopy-a' : 'tree-canopy-b', + })); + g.appendChild(svgEl('rect', { x: (ox - 2).toFixed(1), y: oy.toFixed(1), width: 4, height: 8, class: 'tree-trunk' })); } return g; } @@ -158,7 +264,9 @@ export class BoardView { const to = this.layout.positions.get(childId); const cx = from.x + (to.x - from.x) * 0.5; const d = `M ${from.x} ${from.y} C ${cx} ${from.y}, ${cx} ${to.y}, ${to.x} ${to.y}`; - g.appendChild(svgEl('path', { d, class: 'edge-path' })); + g.appendChild(svgEl('path', { d, class: 'road-shadow' })); + g.appendChild(svgEl('path', { d, class: 'road-base' })); + g.appendChild(svgEl('path', { d, class: 'road-centerline' })); } } return g; @@ -172,6 +280,18 @@ export class BoardView { return g; } + /** One of {circle, diamond, rect}, offset by (dx,dy) — used for the shadow, + * main, and gloss copies of a tile so all three share exact geometry. */ + _shapeEl(shapeType, dx, dy, cls) { + const t = shapeType === 'diamond' ? `translate(${dx},${dy}) rotate(45)` : `translate(${dx},${dy})`; + if (shapeType === 'circle') return svgEl('circle', { r: NODE_H / 2, class: cls, transform: t }); + if (shapeType === 'diamond') { + const r = NODE_H / 2 + 6; + return svgEl('rect', { x: -r, y: -r, width: r * 2, height: r * 2, rx: 8, class: cls, transform: t }); + } + return svgEl('rect', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: cls, transform: t }); + } + _buildNode(space, pos) { const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event; const node = svgEl('g', { @@ -180,19 +300,17 @@ export class BoardView { 'data-space-id': space.id, }); - if (style.shape === 'circle') { - node.appendChild(svgEl('circle', { r: NODE_H / 2, class: 'space-shape' })); - } else if (style.shape === 'diamond') { - const r = NODE_H / 2 + 6; - node.appendChild(svgEl('rect', { - x: -r, y: -r, width: r * 2, height: r * 2, rx: 8, - transform: 'rotate(45)', class: 'space-shape', - })); - } else { - node.appendChild(svgEl('rect', { - x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: 'space-shape', - })); + // A few degrees of deterministic tilt per tile — hand-placed, not + // machine-stamped — applied only to the tile's shape, never its label. + const tilt = ((hashString(space.id) % 700) / 100 - 3.5).toFixed(2); + const shapeGroup = svgEl('g', { class: 'tile-shape-group', transform: `rotate(${tilt})` }); + shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow')); + shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape')); + shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss')); + if (space.type === 'choice') { + shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' })); } + node.appendChild(shapeGroup); const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H }); const div = document.createElement('div'); diff --git a/public/index.html b/public/index.html index 3902e75..9e8b180 100644 --- a/public/index.html +++ b/public/index.html @@ -71,15 +71,35 @@ /* --- Board --- */ .board-container { overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px; - background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%); - border: 1px solid var(--line); + background: #163a30; border: 1px solid var(--line); } .board-svg { display: block; width: 100%; height: auto; min-width: 680px; } - .tree-blob { fill: #2f6b52; opacity: .55; } - .edge-path { fill: none; stroke: #caa15a; stroke-width: 7; stroke-linecap: round; opacity: .9; } + + .field-bg { fill: url(#fieldGradient); } + .mountain-body { fill: #8f9aa8; opacity: .5; } + .mountain-snow { fill: #e8edf2; opacity: .45; } + .river-body { fill: none; stroke: #4a90b8; stroke-width: 34; stroke-linecap: round; opacity: .5; } + .river-shine { fill: none; stroke: #a8d8ea; stroke-width: 8; stroke-linecap: round; opacity: .4; } + .tree-canopy-a { fill: #2f6b52; opacity: .6; } + .tree-canopy-b { fill: #3a7a5e; opacity: .55; } + .tree-trunk { fill: #4a3a2a; opacity: .5; } + + .road-shadow { + fill: none; stroke: #0f2a23; stroke-width: 15; stroke-linecap: round; + opacity: .3; transform: translate(0, 3px); + } + .road-base { fill: none; stroke: #caa15a; stroke-width: 13; stroke-linecap: round; filter: url(#roadWobble); } + .road-centerline { + fill: none; stroke: #f0dcae; stroke-width: 2.5; stroke-linecap: round; + stroke-dasharray: 8 10; opacity: .85; filter: url(#roadWobble); + } + + .space-shadow { fill: rgba(0,0,0,.32); } + .space-gloss { fill: url(#tileGloss); pointer-events: none; } + .choice-post { fill: #8a6a3c; stroke: #5f462a; stroke-width: 1; } .space-shape { fill: #fffaf0; stroke: var(--line); stroke-width: 2.5; - filter: drop-shadow(0 3px 3px rgba(0,0,0,.35)); + filter: drop-shadow(0 1px 1px rgba(0,0,0,.25)); } .space-start .space-shape { fill: var(--good); stroke: #2c6b44; } .space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }