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 @@
-
- The Loom
- {{ figures.length }} figures in the canon
+
+
+
-
+
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 @@
+
+
+
+
+
+
+ {{ row.label }}
+
+
+
+
+
+ {{ editorial[p.figure].name }}
+
+
+
+
+
+
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'