From 6656f91f25b8a76e7b8116fcdd32335337978c4a Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 27 Jul 2026 10:32:59 +0300 Subject: [PATCH] Document Gariff influence workbook and pack timeline lanes by time span. Add Inputs/gariff_influential_painters_influences.xlsx for curator import, document Inputs/Output conventions, and let non-overlapping movements share horizontal lanes on the timeline. Co-authored-by: Cursor --- .gitignore | 9 ++++ Documentation/FAC.md | 4 +- Documentation/basics.md | 6 +-- Documentation/data-and-images.md | 6 ++- Documentation/influence-import.md | 25 ++++++--- ...ariff_influential_painters_influences.xlsx | Bin 0 -> 13101 bytes client/src/components/MovementBands.tsx | 49 +++++------------- 7 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 Inputs/gariff_influential_painters_influences.xlsx diff --git a/.gitignore b/.gitignore index ec7bed2..eb6a242 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,18 @@ infra/deploy/last-docker-release.json # DB backups (may contain data) db/DataBackup/ +# Generated exports / scratch (CSV dumps, PDF text extracts) +Output/ +__pycache__/ + +# Large local book PDFs (keep workbooks under Inputs/*.xlsx) +Inputs/*.pdf + # Recovery / temp files from local restore _extracted/ _parse_*.js +tmp-*.js +tmp-*.py # Logs *.log diff --git a/Documentation/FAC.md b/Documentation/FAC.md index f079480..c9c7363 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -213,7 +213,9 @@ npm run dev:discover-influences # discovery only, no curated insert | `npm run dev:find-duplicates` | Duplicate / near-duplicate painting rows | | `npm run dev:audit-influence-duplicates` | Duplicate influence edges | | `npm run dev:analyze-painter-palette` | CSV ↔ artist name match report | -| `npm run dev:export-paintings` | Write `Output/paintings.csv` | +| `npm run dev:export-paintings` | Write `Output/paintings.csv` (gitignored folder) | + +**Influence workbooks** (curator import): see [influence-import.md](influence-import.md) — e.g. `Inputs/gariff_influential_painters_influences.xlsx`, `Inputs/story_of_art_influences.xlsx`. --- diff --git a/Documentation/basics.md b/Documentation/basics.md index ee3a3b8..0f91ec2 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -76,8 +76,8 @@ Gallery/ │ ├── painter-palette-lib.js │ ├── import-painter-palette.js │ └── image-fetcher.js -├── Inputs/ # External datasets (e.g. PainterPalette.csv) -├── Output/ # Generated exports (e.g. paintings.csv) +├── Inputs/ # External datasets & influence workbooks (PainterPalette, book extracts, …) +├── Output/ # Generated exports (CSV dumps, scratch extracts) — gitignored ├── data/images/ # Local portraits and paintings (+ thumbs/) ├── db/ # schema.sql, setup-admin.sql, migrate-*.sql ├── server/migrate.js # npm run dev:migrate — schema + incremental migrations @@ -246,7 +246,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str | Feature | Implementation | |---------|----------------| | Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed | -| Vertical depth | Successor movements sit on rows below their deepest parent; sibling movements at the same depth are spread into lanes to limit overlap | +| Vertical lanes | Movements whose time spans do **not** overlap (in the current zoom) share a horizontal lane; only concurrent spans stack into extra rows (`assignTemporalLanes` in `MovementBands.tsx`). Lineage depth still drives branch curves, not exclusive vertical bands | | Branch connectors | Smooth curves from the **centre** of a parent stream to the **centre** of each child stream (siblings fan out along the parent’s length) | | Visual blending | Path-aligned SVG gradients with transparent fades at stream ends and branch junctions; streams draw on top of branches so overlap brightness stays uniform | | Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index c2dd14d..794ad3f 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -434,13 +434,15 @@ Re-run `import-painter-palette` after adding gallery artists or updating the CSV ## Catalog export -Export the full painting catalog as CSV: +Export helpers write under **`Output/`** (gitignored — regenerate as needed): ```bash npm run dev:export-paintings ``` -Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). The `Output/` folder is git-ignored by convention; regenerate after catalog changes. +Writes **`Output/paintings.csv`** with columns `artist`, `painting`, `year` (sorted by artist, year, title). + +Ad-hoc **prod** dumps (movements / artists / paintings) can also be written to `Output/*.csv` for offline review. Influence workbooks ready for the curator import wizard belong under **`Inputs/`** (e.g. Gariff, Story of Art) — see [influence-import.md](influence-import.md). ## Image fetcher overrides diff --git a/Documentation/influence-import.md b/Documentation/influence-import.md index 944da4b..61610da 100644 --- a/Documentation/influence-import.md +++ b/Documentation/influence-import.md @@ -30,17 +30,30 @@ Unresolved names (artists / movements / paintings not in the DB) are **skipped** ### Presets -| Preset | Typical headers | -|--------|-----------------| -| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | -| Story of Art | Same Title Case (+ chapter reference column) | -| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | -| Custom | Map any columns manually | +| Preset | Typical headers | Example file | +|--------|-----------------|--------------| +| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) | +| Story of Art | Same Title Case (+ chapter reference column) | [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) | +| Gariff influential painters | `artist`, `painting`, `influenced by`, `influenced`, `reference` | [`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) | +| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | [`Inputs/art_influences.xlsx`](../Inputs/art_influences.xlsx) | +| Custom | Map any columns manually | — | Token classification order: **artist → movement → painting title** (under subject artist, then global). Committed edges use `confidence=curated`, `discovered_via=import-wizard`. +### Book extract: Gariff (2008) + +[`Inputs/gariff_influential_painters_influences.xlsx`](../Inputs/gariff_influential_painters_influences.xlsx) is distilled from David Gariff et al., *The World's Most Influential Painters and the Artists They Inspired* (Herbert Press / Quarto, 2008; local PDF `Inputs/1.pdf`, not committed). + +- Columns match the import wizard (`artist` / `painting` / `influenced by` / `influenced` / `reference`). +- **`artist` and `painting`** are limited to catalog slices in `Inputs/artistslistdbdata-*.csv` and `Inputs/paintinglistdbdata-*.csv` (regenerate those from prod/dev as needed). +- **`influenced by` / `influenced`** may name artists, movements, or works from the book (including entities outside those CSVs). +- Multiple rows per subject when the book states several influences. +- Import via curator **Influences → Import** (Art influences / custom mapping) or keep as a curated source workbook. + +See also [`Inputs/story_of_art_influences.xlsx`](../Inputs/story_of_art_influences.xlsx) (Gombrich) and optional local `Inputs/janson_short_history_influences.xlsx` (same column shape). + ### Duplicate file / data guard Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details): diff --git a/Inputs/gariff_influential_painters_influences.xlsx b/Inputs/gariff_influential_painters_influences.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..41ee71e02f90d69579462712fe98793deb8f9811 GIT binary patch literal 13101 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 [m.name, m.id])); const lineageParents = buildLineageParentMap(visibleMovements, nameToId); const depths = assignDepths(visibleMovements, lineageParents); - const maxDepth = Math.max(...depths.values()); const layoutHeight = Math.max(200, canvasHeight); const usableHeight = layoutHeight - CANVAS_TOP_PAD - CANVAS_BOTTOM_PAD; - const byDepth = new Map(); + // Pack all visible movements into shared horizontal lanes whenever their + // clipped time spans do not overlap (lineage depth is kept for branch curves only). + const laneIndex = assignTemporalLanes(visibleMovements, viewStart, viewEnd); + let maxLanes = 0; + const laneOccupancy = new Map(); for (const movement of visibleMovements) { - const depth = depths.get(movement.id) ?? 0; - const list = byDepth.get(depth) || []; - list.push(movement); - byDepth.set(depth, list); + const lane = laneIndex.get(movement.id) ?? 0; + maxLanes = Math.max(maxLanes, lane + 1); + laneOccupancy.set(lane, (laneOccupancy.get(lane) ?? 0) + 1); } + maxLanes = Math.max(1, maxLanes); - const laneIndex = new Map(); - const maxLanesByDepth = new Map(); - for (const [depth, group] of byDepth.entries()) { - const temporalLanes = assignTemporalLanes(group, viewStart, viewEnd); - let maxLane = 0; - for (const movement of group) { - const lane = temporalLanes.get(movement.id) ?? 0; - laneIndex.set(movement.id, lane); - maxLane = Math.max(maxLane, lane + 1); - } - maxLanesByDepth.set(depth, Math.max(1, maxLane)); - } - - const depthCount = maxDepth + 1; - const depthGaps = Math.max(0, depthCount - 1) * DEPTH_GAP_PX; - const totalLaneSlots = [...maxLanesByDepth.values()].reduce((sum, n) => sum + n, 0); const minLaneStep = MIN_STREAM_STROKE_PX + LANE_GAP_PX; - const laneStep = Math.max(minLaneStep, (usableHeight - depthGaps) / Math.max(1, totalLaneSlots)); + const laneStep = Math.max(minLaneStep, usableHeight / maxLanes); let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - LANE_GAP_PX); streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx); const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96)); const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35)); - const depthBaseY = new Map(); - let yCursor = CANVAS_TOP_PAD; - for (let depth = 0; depth <= maxDepth; depth++) { - depthBaseY.set(depth, yCursor); - const bandLanes = maxLanesByDepth.get(depth) ?? 1; - yCursor += bandLanes * laneStep; - if (depth < maxDepth) yCursor += DEPTH_GAP_PX; - } - const layoutById = new Map(); const branchList: BranchSegment[] = []; const childIdsByParent = new Map(); @@ -1017,10 +994,8 @@ export default function MovementBands({ const depth = depths.get(movement.id) ?? 0; const lane = laneIndex.get(movement.id) ?? 0; - const lanesAtDepth = maxLanesByDepth.get(depth) ?? 1; - const bandTop = depthBaseY.get(depth) ?? CANVAS_TOP_PAD; - const y = bandTop + lane * laneStep + laneStep / 2; - const allowDrift = lanesAtDepth === 1 && laneStep >= 80; + const y = CANVAS_TOP_PAD + lane * laneStep + laneStep / 2; + const allowDrift = (laneOccupancy.get(lane) ?? 0) === 1 && laneStep >= 80; const yEnd = y + (allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0); const parentIds = lineageParents.get(movement.id) || [];