diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 334ecbf..e0a17aa 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,8 +1,10 @@ import { expect, test } from '@playwright/test' +import { figures } from '../src/data/canon.generated' -test('shows the skeleton with canon data', async ({ page }) => { +test('renders the full pill layer', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle('The Loom') - await expect(page.locator('h1')).toHaveText('The Loom') - await expect(page.locator('p')).toHaveText(/\d+ figures in the canon/) + await expect(page.locator('h1')).toHaveText('THE LOOM.') + await expect(page.locator('.pill')).toHaveCount(figures.length) + await expect(page.locator('.pill', { hasText: 'Aeëtes' })).toBeVisible() }) diff --git a/index.html b/index.html index 5bbc4d8..2032df6 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + The Loom diff --git a/src/App.vue b/src/App.vue index 7c51947..de20f7e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,47 @@ - + diff --git a/src/assets/bands.css b/src/assets/bands.css new file mode 100644 index 0000000..e310487 --- /dev/null +++ b/src/assets/bands.css @@ -0,0 +1,9 @@ +:root { + --band-primordial: #9b7fc7; + --band-titan: #c96f4a; + --band-second_generation: #6fb8a3; + --band-olympian: #e8b54a; + --band-hero: #5b9fb8; + --band-mortal: #8a9a5b; + --band-monster: #c77fb8; +} diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..9fcb966 --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,38 @@ +@import './bands.css'; + +:root { + color-scheme: dark; + --bg: #120e09; + --surface: #1b1611; + --panel: #16110c; + --raise: #262019; + --raise2: #221b13; + --ink: #f2e8d8; + --ink2: rgba(242, 232, 216, 0.6); + --ink3: rgba(242, 232, 216, 0.4); + --gold: #e8b54a; + --gold-t: #f2cd7b; + --gold-bg: #33270f; + --line: rgba(233, 181, 74, 0.15); + --line2: rgba(233, 181, 74, 0.3); + --edge: rgba(233, 181, 74, 0.45); + --map1: #221b13; + --map2: #1b1611; + --pill: #262019; + --shadow: rgba(0, 0, 0, 0.6); + --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --serif: 'GFS Didot', Didot, 'Bodoni MT', serif; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: system-ui, sans-serif; +} + +#app { + height: 100dvh; + display: flex; + flex-direction: column; +} diff --git a/src/components/LoomMap.vue b/src/components/LoomMap.vue new file mode 100644 index 0000000..fca56fd --- /dev/null +++ b/src/components/LoomMap.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/data/__tests__/layout.spec.ts b/src/data/__tests__/layout.spec.ts new file mode 100644 index 0000000..8773275 --- /dev/null +++ b/src/data/__tests__/layout.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { bands, figures } from '../canon.generated' +import { + BAND_ORDER, + CANVAS, + CLUSTER_GAP, + COLUMN_UNIT, + rowY, + gapCues, + misplaced, + placementByFigure, + placements, + rowClusters, + unplaced, +} from '../layout' + +const byBand = (band: (typeof BAND_ORDER)[number]) => + placements.filter((p) => p.band === band).sort((a, b) => a.x - b.x) + +describe('layout', () => { + it('leaves no figure unplaced', () => { + expect(unplaced).toEqual([]) + }) + + it('leaves no figure misplaced', () => { + expect(misplaced).toEqual([]) + }) + + it('places each figure exactly once', () => { + const ids = placements.map((p) => p.figure) + expect(ids).toHaveLength(figures.length) + expect(new Set(ids).size).toBe(figures.length) + }) + + it('lays out seven bands as one row each, top to bottom', () => { + expect(BAND_ORDER).toHaveLength(7) + BAND_ORDER.forEach((band) => { + const row = byBand(band) + expect(row.length).toBeGreaterThan(0) + const y = rowY(band) + for (const p of row) expect(p.y).toBe(y) + }) + }) + + it('spaces neighbours one unit apart, one and a half across a gap', () => { + const unit = COLUMN_UNIT + const gapped = unit * (1 + CLUSTER_GAP) + for (const band of BAND_ORDER) { + const row = byBand(band) + for (let i = 1; i < row.length; i++) { + const dx = row[i]!.x - row[i - 1]!.x + expect([unit, gapped]).toContain(dx) + } + } + }) + + it('centres every row on the canvas', () => { + for (const band of BAND_ORDER) { + const row = byBand(band) + const mid = (row[0]!.x + row[row.length - 1]!.x) / 2 + expect(mid).toBeCloseTo(CANVAS.width / 2, 5) + } + }) + + it('keeps every pill centre well inside the canvas', () => { + for (const p of placements) { + expect(p.x).toBeGreaterThanOrEqual(COLUMN_UNIT / 2) + expect(p.x).toBeLessThanOrEqual(CANVAS.width - COLUMN_UNIT / 2) + expect(p.y).toBeGreaterThan(0) + expect(p.y).toBeLessThan(CANVAS.height) + } + }) + + it('marks every cluster boundary with a cue at the gap midpoint', () => { + for (const band of BAND_ORDER) { + const clusters = rowClusters[band] + const cues = gapCues.filter((c) => c.band === band).sort((a, b) => a.x - b.x) + expect(cues).toHaveLength(clusters.length - 1) + let seen = 0 + for (let i = 1; i < clusters.length; i++) { + const prev = clusters[i - 1]! + const left = placementByFigure.get(prev[prev.length - 1]!)! + const right = placementByFigure.get(clusters[i]![0]!)! + expect(cues[seen]!.x).toBeCloseTo((left.x + right.x) / 2, 5) + expect(cues[seen]!.y).toBe(left.y) + seen++ + } + } + }) + + it('keeps each declared cluster contiguous and in declared order', () => { + for (const band of BAND_ORDER) { + const declared = rowClusters[band].flat() + const row = byBand(band).map((p) => p.figure) + expect(row).toEqual(declared) + } + }) +}) + +describe('mortal row derivation', () => { + const clusters = rowClusters.mortal + const clusterOf = (id: string) => clusters.find((c) => c.includes(id as never)) + + it('derives the four story cycles with no authored data', () => { + expect(new Set(clusterOf('agamemnon'))).toEqual( + new Set(['atreus', 'agamemnon', 'menelaus', 'clytemnestra', 'leda', 'helen']), + ) + expect(new Set(clusterOf('hector'))).toEqual( + new Set(['priam', 'hecuba', 'hector', 'paris', 'andromache', 'astyanax']), + ) + expect(new Set(clusterOf('minos'))).toEqual( + new Set(['europa', 'minos', 'androgeos', 'ariadne', 'phaedra']), + ) + expect(new Set(clusterOf('penelope'))).toEqual(new Set(['penelope', 'telemachus'])) + }) + + it('keeps every mortal in its band row', () => { + const mortals = figures.filter((f) => bands[f] === 'mortal') + expect(clusters.flat().slice().sort()).toEqual(mortals.slice().sort()) + }) + + it('ends with the threadless tail in alphabetical order', () => { + const tail = clusters[clusters.length - 1] + expect(tail).toEqual([ + 'alke', + 'argos', + 'elpenor', + 'eurycleia', + 'eurylochos', + 'palamedes', + 'polydamas', + ]) + }) +}) diff --git a/src/data/editorial/figures.ts b/src/data/editorial/figures.ts new file mode 100644 index 0000000..b5515f9 --- /dev/null +++ b/src/data/editorial/figures.ts @@ -0,0 +1,157 @@ +import type { FigureId } from '../canon.generated' + +export interface EditorialFigure { + name: string +} + +export const editorial: Record = { + achelous: { name: 'Achelous' }, + achilles: { name: 'Achilles' }, + aeetes: { name: 'Aeëtes' }, + aello: { name: 'Aello' }, + agamemnon: { name: 'Agamemnon' }, + alcmene: { name: 'Alcmene' }, + alke: { name: 'Alke' }, + amphitrite: { name: 'Amphitrite' }, + androgeos: { name: 'Androgeos' }, + andromache: { name: 'Andromache' }, + aphrodite: { name: 'Aphrodite' }, + apollo: { name: 'Apollo' }, + apple_serpent: { name: 'Ladon' }, + ares: { name: 'Ares' }, + argos: { name: 'Argos' }, + ariadne: { name: 'Ariadne' }, + artemis: { name: 'Artemis' }, + astraios: { name: 'Astraios' }, + astyanax: { name: 'Astyanax' }, + athena: { name: 'Athena' }, + atlas: { name: 'Atlas' }, + atreus: { name: 'Atreus' }, + bellerophon: { name: 'Bellerophon' }, + boreas: { name: 'Boreas' }, + callirhoe: { name: 'Callirhoë' }, + calypso: { name: 'Calypso' }, + cerberus: { name: 'Cerberus' }, + ceto: { name: 'Ceto' }, + chaos: { name: 'Chaos' }, + chimera: { name: 'Chimera' }, + chrysaor: { name: 'Chrysaor' }, + circe: { name: 'Circe' }, + clymene: { name: 'Clymene' }, + clytemnestra: { name: 'Clytemnestra' }, + coeus: { name: 'Coeus' }, + crius: { name: 'Crius' }, + daedalus: { name: 'Daedalus' }, + demeter: { name: 'Demeter' }, + diomedes: { name: 'Diomedes' }, + dione: { name: 'Dione' }, + dionysus: { name: 'Dionysus' }, + doris: { name: 'Doris' }, + echidna: { name: 'Echidna' }, + eileithyia: { name: 'Eileithyia' }, + electra_oceanid: { name: 'Electra' }, + elpenor: { name: 'Elpenor' }, + enyo_graia: { name: 'Enyo' }, + eos: { name: 'Eos' }, + epimetheus: { name: 'Epimetheus' }, + erebus: { name: 'Erebus' }, + eros: { name: 'Eros' }, + euryale: { name: 'Euryale' }, + eurybia: { name: 'Eurybia' }, + eurycleia: { name: 'Eurycleia' }, + eurylochos: { name: 'Eurylochos' }, + eurytion: { name: 'Eurytion' }, + europa: { name: 'Europa' }, + galatea: { name: 'Galatea' }, + gaia: { name: 'Gaia' }, + geryon: { name: 'Geryon' }, + glaucos: { name: 'Glaucos' }, + hades: { name: 'Hades' }, + hebe: { name: 'Hebe' }, + hector: { name: 'Hector' }, + hecuba: { name: 'Hecuba' }, + helen: { name: 'Helen' }, + helios: { name: 'Helios' }, + hephaestus: { name: 'Hephaestus' }, + hera: { name: 'Hera' }, + heracles: { name: 'Heracles' }, + hermes: { name: 'Hermes' }, + hestia: { name: 'Hestia' }, + hydra: { name: 'Hydra' }, + hyperion: { name: 'Hyperion' }, + iapetus: { name: 'Iapetus' }, + icarus: { name: 'Icarus' }, + iolaus: { name: 'Iolaus' }, + iris: { name: 'Iris' }, + jason: { name: 'Jason' }, + kronos: { name: 'Kronos' }, + lampetia: { name: 'Lampetia' }, + leda: { name: 'Leda' }, + leto: { name: 'Leto' }, + maia: { name: 'Maia' }, + medea: { name: 'Medea' }, + medusa: { name: 'Medusa' }, + melia: { name: 'Melia' }, + menelaus: { name: 'Menelaus' }, + menoetius: { name: 'Menoetius' }, + metis: { name: 'Metis' }, + minos: { name: 'Minos' }, + mnemosyne: { name: 'Mnemosyne' }, + neaera: { name: 'Neaera' }, + nemean_lion: { name: 'Nemean Lion' }, + nereus: { name: 'Nereus' }, + nyx: { name: 'Nyx' }, + oceanos: { name: 'Oceanos' }, + ocypeta: { name: 'Ocypete' }, + odysseus: { name: 'Odysseus' }, + oedipus: { name: 'Oedipus' }, + orthos: { name: 'Orthos' }, + palamedes: { name: 'Palamedes' }, + pallas: { name: 'Pallas' }, + paris: { name: 'Paris' }, + pasiphae: { name: 'Pasiphaë' }, + patroclus: { name: 'Patroclus' }, + pegasus: { name: 'Pegasus' }, + peleus: { name: 'Peleus' }, + pemphredo: { name: 'Pemphredo' }, + penelope: { name: 'Penelope' }, + persephone: { name: 'Persephone' }, + perse: { name: 'Perse' }, + perses_colchis: { name: 'Perses of Colchis' }, + perses_titan: { name: 'Perses' }, + perseus: { name: 'Perseus' }, + phaedra: { name: 'Phaedra' }, + phaethousa: { name: 'Phaethousa' }, + phoebe: { name: 'Phoebe' }, + phorcys: { name: 'Phorcys' }, + pleione: { name: 'Pleione' }, + polydamas: { name: 'Polydamas' }, + polyphemus: { name: 'Polyphemus' }, + pontus: { name: 'Pontus' }, + poseidon: { name: 'Poseidon' }, + priam: { name: 'Priam' }, + prometheus: { name: 'Prometheus' }, + proteus: { name: 'Proteus' }, + psamathe: { name: 'Psamathe' }, + pyrrhus: { name: 'Pyrrhus' }, + rhea: { name: 'Rhea' }, + scylla: { name: 'Scylla' }, + selene: { name: 'Selene' }, + semele: { name: 'Semele' }, + sphinx: { name: 'Sphinx' }, + sthenno: { name: 'Sthenno' }, + styx: { name: 'Styx' }, + tartarus: { name: 'Tartarus' }, + telegonus: { name: 'Telegonus' }, + telemachus: { name: 'Telemachus' }, + tethys: { name: 'Tethys' }, + thaumas: { name: 'Thaumas' }, + the_fury: { name: 'The Fury' }, + theia: { name: 'Theia' }, + themis: { name: 'Themis' }, + theseus: { name: 'Theseus' }, + thetis: { name: 'Thetis' }, + typhoeus: { name: 'Typhoeus' }, + uranus: { name: 'Uranus' }, + zeus: { name: 'Zeus' }, +} diff --git a/src/data/layout.ts b/src/data/layout.ts new file mode 100644 index 0000000..59c460c --- /dev/null +++ b/src/data/layout.ts @@ -0,0 +1,295 @@ +import { + type Band, + type FigureId, + bands, + couples, + figures, + parents, + slayings, +} from './canon.generated' +import { editorial } from './editorial/figures' + +const CHAR_WIDTH = 7.2 +const PILL_CHROME = 46 +const MIN_PILL_GAP = 16 + +const longestName = Math.max(...Object.values(editorial).map((e) => e.name.length)) + +const PILL_MAX_WIDTH = Math.ceil(longestName * CHAR_WIDTH + PILL_CHROME) +export const COLUMN_UNIT = PILL_MAX_WIDTH + MIN_PILL_GAP +export const ROW_TOP = 70 +export const ROW_PITCH = 168 +export const CLUSTER_GAP = 0.5 +const CANVAS_MARGIN = 100 + +export const BAND_ORDER: readonly Band[] = [ + 'primordial', + 'titan', + 'second_generation', + 'olympian', + 'hero', + 'mortal', + 'monster', +] + +const declared: Record, FigureId[][]> = { + primordial: [ + ['chaos', 'nyx', 'erebus'], + ['the_fury', 'uranus', 'gaia', 'pontus', 'tartarus'], + ['eros'], + ], + titan: [ + ['oceanos', 'tethys'], + ['coeus', 'phoebe'], + ['iapetus'], + ['kronos', 'rhea'], + ['hyperion', 'theia'], + ['crius'], + ['mnemosyne', 'themis'], + ], + olympian: [ + ['hades', 'persephone', 'demeter'], + ['hestia', 'poseidon'], + [ + 'hephaestus', + 'eileithyia', + 'hebe', + 'ares', + 'hera', + 'zeus', + 'athena', + 'apollo', + 'artemis', + 'hermes', + 'dionysus', + ], + ['aphrodite'], + ], + second_generation: [ + ['achelous', 'melia', 'styx', 'dione', 'metis'], + ['leto'], + ['clymene', 'prometheus', 'epimetheus', 'menoetius', 'atlas', 'pleione', 'maia', 'calypso'], + [ + 'selene', + 'eos', + 'helios', + 'perse', + 'circe', + 'aeetes', + 'perses_colchis', + 'pasiphae', + 'neaera', + 'lampetia', + 'phaethousa', + ], + ['boreas', 'astraios', 'pallas', 'perses_titan', 'eurybia'], + [ + 'proteus', + 'glaucos', + 'nereus', + 'doris', + 'thetis', + 'psamathe', + 'galatea', + 'amphitrite', + 'thaumas', + 'electra_oceanid', + 'iris', + 'aello', + 'ocypeta', + 'phorcys', + 'ceto', + 'chrysaor', + 'callirhoe', + ], + ], + hero: [ + ['perseus', 'bellerophon', 'heracles', 'oedipus'], + ['daedalus', 'theseus'], + ['jason', 'medea'], + ['achilles', 'diomedes', 'odysseus'], + ], + monster: [ + ['pemphredo', 'enyo_graia', 'sthenno', 'euryale', 'medusa', 'pegasus', 'apple_serpent'], + ['geryon'], + ['typhoeus', 'echidna', 'orthos', 'cerberus', 'chimera', 'hydra', 'nemean_lion', 'sphinx'], + ['scylla', 'polyphemus'], + ], +} + +interface Relation { + a: FigureId + b: FigureId +} + +const relations: Relation[] = [ + ...parents.map((p) => ({ a: p.parent, b: p.child })), + ...couples.map((c) => ({ a: c.a, b: c.b })), + ...slayings.map((s) => ({ a: s.monster, b: s.slayer })), +] + +function mortalComponents() { + const mortals = figures.filter((f) => bands[f] === 'mortal') + const root = new Map(mortals.map((m) => [m, m])) + const find = (x: FigureId): FigureId => { + const parent = root.get(x)! + if (parent === x) return x + const r = find(parent) + root.set(x, r) + return r + } + + const outOfBand = new Map() + for (const { a, b } of relations) { + const aMortal = bands[a] === 'mortal' + const bMortal = bands[b] === 'mortal' + if (aMortal && bMortal) root.set(find(a), find(b)) + else if (aMortal) outOfBand.set(a, [...(outOfBand.get(a) ?? []), b]) + else if (bMortal) outOfBand.set(b, [...(outOfBand.get(b) ?? []), a]) + } + + const members = new Map() + for (const m of mortals) members.set(find(m), [...(members.get(find(m)) ?? []), m]) + + const units: FigureId[][] = [] + const tail: FigureId[] = [] + for (const cluster of members.values()) { + if (cluster.length === 1 && !outOfBand.has(cluster[0]!)) tail.push(cluster[0]!) + else units.push(cluster) + } + tail.sort() + return { units, tail, outOfBand } +} + +function orderMortalClusters( + { units, tail, outOfBand }: ReturnType, + xOutOfBand: (id: FigureId) => number | undefined, + centre: number, +): FigureId[][] { + const anchored = units.map((cluster) => { + const anchors = cluster + .flatMap((m) => outOfBand.get(m) ?? []) + .map(xOutOfBand) + .filter((x): x is number => x !== undefined) + const anchor = anchors.length > 0 ? anchors.reduce((s, x) => s + x, 0) / anchors.length : centre + return { order: orderCycle(cluster), anchor } + }) + anchored.sort((a, b) => a.anchor - b.anchor || a.order[0]!.localeCompare(b.order[0]!)) + const ordered = anchored.map((c) => c.order) + return tail.length > 0 ? [...ordered, tail] : ordered +} + +function orderCycle(cluster: FigureId[]): FigureId[] { + const here = new Set(cluster) + const childrenOf = (x: FigureId) => + parents + .filter((p) => p.parent === x && here.has(p.child)) + .map((p) => p.child) + .sort() + const partnersOf = (x: FigureId) => + couples + .filter((c) => c.a === x || c.b === x) + .map((c) => (c.a === x ? c.b : c.a)) + .filter((y) => here.has(y)) + .sort() + const hasParentHere = (x: FigureId) => parents.some((p) => p.child === x && here.has(p.parent)) + + const order: FigureId[] = [] + const seen = new Set() + const visit = (x: FigureId) => { + if (seen.has(x)) return + seen.add(x) + order.push(x) + for (const partner of partnersOf(x)) visit(partner) + for (const child of childrenOf(x)) visit(child) + } + const roots = cluster.filter((m) => !hasParentHere(m)).sort() + for (const r of [...roots, ...cluster.slice().sort()]) visit(r) + return order +} + +export interface Placement { + figure: FigureId + band: Band + x: number + y: number +} + +export interface GapCue { + band: Band + x: number + y: number +} + +const rowSpan = (count: number, clusters: number) => + (count - 1 + (clusters - 1) * CLUSTER_GAP) * COLUMN_UNIT + +export const rowY = (band: Band) => ROW_TOP + BAND_ORDER.indexOf(band) * ROW_PITCH + +const mortals = mortalComponents() +const mortalUnits = mortals.tail.length > 0 ? [...mortals.units, mortals.tail] : mortals.units + +const spanOf = (clusters: FigureId[][]) => + rowSpan( + clusters.reduce((n, c) => n + c.length, 0), + clusters.length, + ) + +const widestSpan = Math.max( + ...BAND_ORDER.map((band) => spanOf(band === 'mortal' ? mortalUnits : declared[band])), +) + +export const CANVAS = { + width: Math.ceil(widestSpan) + 2 * CANVAS_MARGIN, + height: ROW_TOP + BAND_ORDER.length * ROW_PITCH, +} as const + +function placeRow(band: Band, clusters: FigureId[][]) { + const y = rowY(band) + const placements: Placement[] = [] + const cues: GapCue[] = [] + let x = (CANVAS.width - spanOf(clusters)) / 2 + clusters.forEach((cluster, i) => { + if (i > 0) cues.push({ band, x: x - ((1 + CLUSTER_GAP) / 2) * COLUMN_UNIT, y }) + for (const figure of cluster) { + placements.push({ figure, band, x, y }) + x += COLUMN_UNIT + } + x += CLUSTER_GAP * COLUMN_UNIT + }) + return { placements, cues } +} + +const xOf = new Map( + BAND_ORDER.filter((band): band is Exclude => band !== 'mortal') + .flatMap((band) => placeRow(band, declared[band]).placements) + .map((p) => [p.figure, p.x]), +) + +export const rowClusters: Record = { + ...declared, + mortal: orderMortalClusters(mortals, (id) => xOf.get(id), CANVAS.width / 2), +} + +const rows = BAND_ORDER.map((band) => placeRow(band, rowClusters[band])) + +export const placements: readonly Placement[] = rows.flatMap((r) => r.placements) + +export const placementByFigure: ReadonlyMap = new Map( + placements.map((p) => [p.figure, p]), +) + +export const gapCues: readonly GapCue[] = rows.flatMap((r) => r.cues) + +export const unplaced: FigureId[] = figures.filter((f) => !placementByFigure.has(f)) + +export const misplaced: { figure: FigureId; expected: Band; actual: Band }[] = placements + .filter((p) => p.band !== bands[p.figure]) + .map((p) => ({ figure: p.figure, expected: bands[p.figure], actual: p.band })) + +if (import.meta.env?.DEV) { + for (const f of unplaced) console.warn(`layout: unplaced figure '${f}'`) + for (const m of misplaced) { + console.warn(`layout: '${m.figure}' placed on ${m.actual}, banded ${m.expected}`) + } +} diff --git a/src/main.ts b/src/main.ts index 912d54f..8377882 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,5 @@ +import './assets/main.css' + import { createApp } from 'vue' import App from './App.vue'