Enhance timeline and movement flow UI; add fetched painting images.
Timeline: era and historical-event click-to-zoom, event markers from historical-events.ts. Movement flow: shared zoom/pan, viewport-fit layout, curved lineage streams, hover-to-reveal artist lifespans on stacked lanes. Docs updated. Includes batch-fetched paintings and regenerated thumbs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
35c337253d
commit
0ece1195fa
@@ -1,83 +1,183 @@
|
||||
.movements-container {
|
||||
padding: 16px;
|
||||
.movements-flow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.movements-flow-caption {
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
}
|
||||
|
||||
.movements-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.movement-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 90px;
|
||||
.movements-flow-canvas {
|
||||
--stream-stroke: 54px;
|
||||
--portrait-size: 52px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 100%, rgba(201, 169, 110, 0.07), transparent 70%),
|
||||
rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(201, 169, 110, 0.12);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.movement-label {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-right: 12px;
|
||||
text-align: right;
|
||||
.movements-flow-canvas.movements-flow-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.movements-flow-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movement-branch {
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-stream-fill {
|
||||
stroke-width: var(--stream-stroke);
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.movement-stream-core {
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.22;
|
||||
stroke-dasharray: 8 6;
|
||||
animation: stream-shimmer 16s linear infinite;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
@keyframes stream-shimmer {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -42;
|
||||
}
|
||||
}
|
||||
|
||||
.movements-flow-labels,
|
||||
.movements-flow-artists {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.movements-flow-artists {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.artist-lifespan {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.artist-lifespan-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 0%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 12%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 85%, #fff) 50%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 70%, #e8d5b5) 88%,
|
||||
color-mix(in srgb, var(--lifespan-color, #e8d5b5) 25%, transparent) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--lifespan-color, #e8d5b5) 40%, transparent);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.artist-lifespan .artist-portrait:hover {
|
||||
transform: translate(-50%, -50%) scale(1.14);
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
position: absolute;
|
||||
transform: translate(-4px, -100%);
|
||||
max-width: 160px;
|
||||
padding: 2px 6px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.movement-name {
|
||||
display: block;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #e8d5b5;
|
||||
color: rgba(232, 213, 181, 0.95);
|
||||
line-height: 1.25;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.movement-era {
|
||||
font-size: 10px;
|
||||
color: rgba(201, 169, 110, 0.5);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.movement-band-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.1);
|
||||
}
|
||||
|
||||
.movement-band {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
border-radius: 3px;
|
||||
min-width: 40px;
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: rgba(201, 169, 110, 0.6);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.artist-portrait {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: none;
|
||||
border: 2px solid rgba(201, 169, 110, 0.6);
|
||||
border: 2px solid rgba(232, 213, 181, 0.75);
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: var(--portrait-size, 52px);
|
||||
height: var(--portrait-size, 52px);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
z-index: 2;
|
||||
z-index: 5;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.artist-portrait:hover {
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
border-color: #c9a96e;
|
||||
box-shadow: 0 4px 16px rgba(201, 169, 110, 0.4);
|
||||
border-color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(201, 169, 110, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -108,11 +208,25 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.movement-label {
|
||||
width: 100px;
|
||||
.movements-flow-canvas {
|
||||
--stream-stroke: 44px;
|
||||
--portrait-size: 44px;
|
||||
}
|
||||
|
||||
.movements-flow-caption {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.artist-portrait {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.movement-flow-label {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.movement-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import type { ArtMovement, Artist } from '../types';
|
||||
import { imageUrl } from '../api/client';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import './MovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -7,9 +10,39 @@ interface Props {
|
||||
artists: Artist[];
|
||||
viewStart: number;
|
||||
viewEnd: number;
|
||||
absoluteMin: number;
|
||||
absoluteMax: number;
|
||||
onViewChange: (start: number, end: number) => void;
|
||||
onArtistClick: (artistId: number) => void;
|
||||
}
|
||||
|
||||
interface MovementLayout {
|
||||
movement: ArtMovement;
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
y: number;
|
||||
yEnd: number;
|
||||
depth: number;
|
||||
parentIds: number[];
|
||||
}
|
||||
|
||||
interface BranchSegment {
|
||||
d: string;
|
||||
colorFrom: string;
|
||||
colorTo: string;
|
||||
key: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
const MAX_STREAM_STROKE_PX = 54;
|
||||
const MIN_STREAM_STROKE_PX = 28;
|
||||
const CANVAS_TOP_PAD = 38;
|
||||
const CANVAS_BOTTOM_PAD = 24;
|
||||
const DEFAULT_CANVAS_HEIGHT = 360;
|
||||
|
||||
function yearToPercent(year: number, start: number, end: number): number {
|
||||
return ((year - start) / (end - start)) * 100;
|
||||
}
|
||||
@@ -27,17 +60,525 @@ function artistTimelineYear(artist: Artist): number {
|
||||
return artist.birth_year ?? artist.death_year ?? 0;
|
||||
}
|
||||
|
||||
export default function MovementBands({ movements, artists, viewStart, viewEnd, onArtistClick }: Props) {
|
||||
const artistsByMovement = new Map<number, Artist[]>();
|
||||
for (const artist of artists) {
|
||||
if (!artist.movement_id || !artistInView(artist, viewStart, viewEnd)) continue;
|
||||
const list = artistsByMovement.get(artist.movement_id) || [];
|
||||
list.push(artist);
|
||||
artistsByMovement.set(artist.movement_id, list);
|
||||
interface ArtistLifespanLayout {
|
||||
lineLeft: number;
|
||||
lineWidth: number;
|
||||
lineRight: number;
|
||||
portraitLeft: number;
|
||||
y: number;
|
||||
centerX: number;
|
||||
}
|
||||
|
||||
function artistLifespanLayout(
|
||||
artist: Artist,
|
||||
layout: MovementLayout,
|
||||
viewStart: number,
|
||||
viewEnd: number
|
||||
): ArtistLifespanLayout | null {
|
||||
const birth = artist.birth_year ?? viewStart;
|
||||
const death = artist.death_year ?? viewEnd;
|
||||
const centerYear = artistTimelineYear(artist);
|
||||
|
||||
const lineLeft = yearToPercent(Math.max(birth, viewStart), viewStart, viewEnd);
|
||||
const lineRight = yearToPercent(Math.min(death, viewEnd), viewStart, viewEnd);
|
||||
const centerX = yearToPercent(centerYear, viewStart, viewEnd);
|
||||
const lineWidth = lineRight - lineLeft;
|
||||
|
||||
if (lineWidth <= 0) return null;
|
||||
|
||||
const yAnchorX = Math.min(Math.max(centerX, layout.xStart), layout.xEnd);
|
||||
const y = yOnStream(layout, yAnchorX);
|
||||
const portraitLeft = ((centerX - lineLeft) / lineWidth) * 100;
|
||||
|
||||
return { lineLeft, lineWidth, lineRight, portraitLeft, y, centerX };
|
||||
}
|
||||
|
||||
interface ArtistPlacement {
|
||||
layout: MovementLayout;
|
||||
artist: Artist;
|
||||
lineLeft: number;
|
||||
lineWidth: number;
|
||||
portraitLeft: number;
|
||||
y: number;
|
||||
lane: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: normalized.padStart(6, '0').slice(0, 6);
|
||||
return [
|
||||
parseInt(value.slice(0, 2), 16),
|
||||
parseInt(value.slice(2, 4), 16),
|
||||
parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
||||
}
|
||||
|
||||
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
|
||||
if (laneCount <= 1) return baseColor;
|
||||
try {
|
||||
const [r, g, b] = parseHexColor(baseColor);
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const t = laneIndex / Math.max(laneCount - 1, 1) - 0.5;
|
||||
return hslToHex((h + t * 26 + 360) % 360, Math.min(1, s * 1.05), Math.min(0.82, Math.max(0.32, l + t * 0.14)));
|
||||
} catch {
|
||||
return baseColor;
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtistPlacements(
|
||||
layouts: MovementLayout[],
|
||||
artistsByMovement: Map<number, Artist[]>,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
streamStrokePx: number,
|
||||
portraitSizePx: number
|
||||
): ArtistPlacement[] {
|
||||
const placements: ArtistPlacement[] = [];
|
||||
|
||||
for (const layout of layouts) {
|
||||
const movementArtists = artistsByMovement.get(layout.movement.id) || [];
|
||||
const candidates: Array<{
|
||||
artist: Artist;
|
||||
span: ArtistLifespanLayout;
|
||||
}> = [];
|
||||
|
||||
for (const artist of movementArtists) {
|
||||
const span = artistLifespanLayout(artist, layout, viewStart, viewEnd);
|
||||
if (span) candidates.push({ artist, span });
|
||||
}
|
||||
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
a.span.lineLeft - b.span.lineLeft ||
|
||||
b.span.lineWidth - a.span.lineWidth ||
|
||||
a.artist.birth_year! - b.artist.birth_year!
|
||||
);
|
||||
|
||||
const laneEnds: number[] = [];
|
||||
const minGap = 0.6;
|
||||
const assigned: Array<{ candidate: (typeof candidates)[0]; lane: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let lane = laneEnds.findIndex((end) => candidate.span.lineLeft >= end + minGap);
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(candidate.span.lineRight);
|
||||
} else {
|
||||
laneEnds[lane] = Math.max(laneEnds[lane], candidate.span.lineRight);
|
||||
}
|
||||
assigned.push({ candidate, lane });
|
||||
}
|
||||
|
||||
const laneCount = Math.max(laneEnds.length, 1);
|
||||
const laneStep = Math.min(
|
||||
portraitSizePx * 0.52,
|
||||
(streamStrokePx * 0.88) / laneCount
|
||||
);
|
||||
|
||||
for (const { candidate, lane } of assigned) {
|
||||
const { artist, span } = candidate;
|
||||
const yOffset = (lane - (laneCount - 1) / 2) * laneStep;
|
||||
|
||||
placements.push({
|
||||
layout,
|
||||
artist,
|
||||
lineLeft: span.lineLeft,
|
||||
lineWidth: span.lineWidth,
|
||||
portraitLeft: span.portraitLeft,
|
||||
y: span.y + yOffset,
|
||||
lane,
|
||||
color: artistLifespanColor(layout.movement.color, lane, laneCount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visibleMovements = movements.filter(
|
||||
(m) => m.end_year >= viewStart && m.start_year <= viewEnd && (artistsByMovement.get(m.id)?.length ?? 0) > 0
|
||||
return placements;
|
||||
}
|
||||
|
||||
function organicDrift(id: number): number {
|
||||
return ((id * 17) % 11) - 5;
|
||||
}
|
||||
|
||||
function buildLineageParentMap(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
): Map<number, number[]> {
|
||||
const visibleIds = new Set(visible.map((m) => m.id));
|
||||
const childToParents = new Map<number, number[]>();
|
||||
|
||||
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
|
||||
const parentId = nameToId.get(parentName);
|
||||
const childId = nameToId.get(childName);
|
||||
if (!parentId || !childId) continue;
|
||||
if (!visibleIds.has(parentId) || !visibleIds.has(childId)) continue;
|
||||
|
||||
const list = childToParents.get(childId) || [];
|
||||
if (!list.includes(parentId)) list.push(parentId);
|
||||
childToParents.set(childId, list);
|
||||
}
|
||||
|
||||
return childToParents;
|
||||
}
|
||||
|
||||
function assignDepths(movements: ArtMovement[], lineageParents: Map<number, number[]>): Map<number, number> {
|
||||
const visibleIds = new Set(movements.map((m) => m.id));
|
||||
const depth = new Map<number, number>();
|
||||
|
||||
function depthFor(id: number, visiting = new Set<number>()): number {
|
||||
if (depth.has(id)) return depth.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
|
||||
const parents = (lineageParents.get(id) || []).filter((pid) => visibleIds.has(pid));
|
||||
if (parents.length === 0) {
|
||||
depth.set(id, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const d = 1 + Math.max(...parents.map((pid) => depthFor(pid, visiting)));
|
||||
depth.set(id, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
for (const m of movements) depthFor(m.id);
|
||||
return depth;
|
||||
}
|
||||
|
||||
function streamPath(xStart: number, xEnd: number, yStart: number, yEnd: number): string {
|
||||
const width = Math.max(xEnd - xStart, 0.5);
|
||||
const c1x = xStart + width * 0.32;
|
||||
const c2x = xEnd - width * 0.32;
|
||||
return `M ${xStart} ${yStart} C ${c1x} ${yStart - 14}, ${c2x} ${yEnd + 14}, ${xEnd} ${yEnd}`;
|
||||
}
|
||||
|
||||
function yOnStream(layout: MovementLayout, x: number): number {
|
||||
const t = layout.xEnd > layout.xStart ? (x - layout.xStart) / (layout.xEnd - layout.xStart) : 0;
|
||||
const clamped = Math.min(1, Math.max(0, t));
|
||||
return layout.y + (layout.yEnd - layout.y) * clamped;
|
||||
}
|
||||
|
||||
function branchPath(xFrom: number, yFrom: number, xTo: number, yTo: number): string {
|
||||
const dx = xTo - xFrom;
|
||||
const dy = yTo - yFrom;
|
||||
const c1x = xFrom + dx * 0.38;
|
||||
const c1y = yFrom + dy * 0.08;
|
||||
const c2x = xTo - dx * 0.38;
|
||||
const c2y = yTo - dy * 0.08;
|
||||
return `M ${xFrom} ${yFrom} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${xTo} ${yTo}`;
|
||||
}
|
||||
|
||||
function branchOriginOnParent(parent: MovementLayout, childIndex: number, childCount: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const t = childCount === 1 ? 0.5 : (childIndex + 1) / (childCount + 1);
|
||||
const x = parent.xStart + t * (parent.xEnd - parent.xStart);
|
||||
return { x, y: yOnStream(parent, x) };
|
||||
}
|
||||
|
||||
function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
|
||||
const x = (layout.xStart + layout.xEnd) / 2;
|
||||
return { x, y: yOnStream(layout, x) };
|
||||
}
|
||||
|
||||
export default function MovementBands({
|
||||
movements,
|
||||
artists,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax,
|
||||
onViewChange,
|
||||
onArtistClick,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
|
||||
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const next = zoomTimelineView(
|
||||
e.clientX,
|
||||
rect.left,
|
||||
rect.width,
|
||||
e.deltaY,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChange(next.start, next.end);
|
||||
},
|
||||
[viewStart, viewEnd, absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setPanning(true);
|
||||
panStart.current = { x: e.clientX, viewStart, viewEnd };
|
||||
},
|
||||
[viewStart, viewEnd]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panning) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dx = e.clientX - panStart.current.x;
|
||||
const next = panTimelineView(
|
||||
dx,
|
||||
rect.width,
|
||||
panStart.current.viewStart,
|
||||
panStart.current.viewEnd,
|
||||
absoluteMin,
|
||||
absoluteMax
|
||||
);
|
||||
onViewChange(next.start, next.end);
|
||||
};
|
||||
|
||||
const onUp = () => setPanning(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [panning, absoluteMin, absoluteMax, onViewChange]);
|
||||
|
||||
const artistsByMovement = useMemo(() => {
|
||||
const map = new Map<number, Artist[]>();
|
||||
for (const artist of artists) {
|
||||
if (!artist.movement_id || !artistInView(artist, viewStart, viewEnd)) continue;
|
||||
const list = map.get(artist.movement_id) || [];
|
||||
list.push(artist);
|
||||
map.set(artist.movement_id, list);
|
||||
}
|
||||
return map;
|
||||
}, [artists, viewStart, viewEnd]);
|
||||
|
||||
const visibleMovements = useMemo(
|
||||
() =>
|
||||
movements.filter(
|
||||
(m) =>
|
||||
m.end_year >= viewStart &&
|
||||
m.start_year <= viewEnd &&
|
||||
(artistsByMovement.get(m.id)?.length ?? 0) > 0
|
||||
),
|
||||
[movements, viewStart, viewEnd, artistsByMovement]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
if (h > 0) setCanvasHeight(Math.round(h));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const h = entries[0]?.contentRect.height;
|
||||
if (h > 0) setCanvasHeight(Math.round(h));
|
||||
});
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, [visibleMovements.length]);
|
||||
|
||||
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx } =
|
||||
useMemo(() => {
|
||||
if (visibleMovements.length === 0) {
|
||||
return {
|
||||
layouts: [] as MovementLayout[],
|
||||
layoutHeight: DEFAULT_CANVAS_HEIGHT,
|
||||
branches: [] as BranchSegment[],
|
||||
childIdsByParent: new Map<number, number[]>(),
|
||||
streamStrokePx: MAX_STREAM_STROKE_PX,
|
||||
portraitSizePx: 52,
|
||||
};
|
||||
}
|
||||
|
||||
const nameToId = new Map(movements.map((m) => [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 depthCount = maxDepth + 1;
|
||||
const rowStep = usableHeight / depthCount;
|
||||
|
||||
const byDepth = new Map<number, ArtMovement[]>();
|
||||
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 maxLaneCount = Math.max(1, ...[...byDepth.values()].map((g) => g.length));
|
||||
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, rowStep * 0.68);
|
||||
if (maxLaneCount > 1) {
|
||||
streamStrokePx = Math.min(streamStrokePx, (rowStep * 0.92) / maxLaneCount);
|
||||
}
|
||||
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
|
||||
const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96));
|
||||
const driftScale = rowStep < 72 ? 0 : rowStep < 88 ? 0.12 : 0.3;
|
||||
|
||||
const layoutById = new Map<number, MovementLayout>();
|
||||
const branchList: BranchSegment[] = [];
|
||||
const childIdsByParent = new Map<number, number[]>();
|
||||
|
||||
const laneIndex = new Map<number, number>();
|
||||
for (const group of byDepth.values()) {
|
||||
group.sort((a, b) => a.start_year - b.start_year || a.id - b.id);
|
||||
group.forEach((m, i) => laneIndex.set(m.id, i));
|
||||
}
|
||||
|
||||
for (const movement of visibleMovements) {
|
||||
const xStart = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
||||
const xEnd = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (xEnd <= xStart) continue;
|
||||
|
||||
const depth = depths.get(movement.id) ?? 0;
|
||||
const lane = laneIndex.get(movement.id) ?? 0;
|
||||
const laneCount = byDepth.get(depth)?.length ?? 1;
|
||||
const laneSpread =
|
||||
laneCount > 1
|
||||
? Math.max(0, (rowStep - streamStrokePx * 0.12) / (laneCount - 1))
|
||||
: 0;
|
||||
const y =
|
||||
CANVAS_TOP_PAD +
|
||||
depth * rowStep +
|
||||
lane * laneSpread +
|
||||
organicDrift(movement.id) * driftScale;
|
||||
const yEnd = y + organicDrift(movement.id + 1000) * driftScale * 0.7;
|
||||
const parentIds = lineageParents.get(movement.id) || [];
|
||||
|
||||
layoutById.set(movement.id, {
|
||||
movement,
|
||||
xStart,
|
||||
xEnd,
|
||||
y,
|
||||
yEnd,
|
||||
depth,
|
||||
parentIds,
|
||||
});
|
||||
}
|
||||
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
const children = childIdsByParent.get(parentId) || [];
|
||||
children.push(layout.movement.id);
|
||||
childIdsByParent.set(parentId, children);
|
||||
}
|
||||
}
|
||||
|
||||
for (const children of childIdsByParent.values()) {
|
||||
children.sort((a, b) => {
|
||||
const la = layoutById.get(a)!;
|
||||
const lb = layoutById.get(b)!;
|
||||
return la.y - lb.y || la.movement.start_year - lb.movement.start_year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const layout of layoutById.values()) {
|
||||
for (const parentId of layout.parentIds) {
|
||||
const parent = layoutById.get(parentId);
|
||||
if (!parent) continue;
|
||||
|
||||
const children = childIdsByParent.get(parentId)!;
|
||||
const childIndex = children.indexOf(layout.movement.id);
|
||||
const origin = branchOriginOnParent(parent, childIndex, children.length);
|
||||
const target = branchTargetOnChild(layout);
|
||||
|
||||
branchList.push({
|
||||
key: `${parentId}-${layout.movement.id}`,
|
||||
d: branchPath(origin.x, origin.y, target.x, target.y),
|
||||
colorFrom: parent.movement.color,
|
||||
colorTo: layout.movement.color,
|
||||
x1: origin.x,
|
||||
y1: origin.y,
|
||||
x2: target.x,
|
||||
y2: target.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layouts: [...layoutById.values()].sort((a, b) => a.depth - b.depth),
|
||||
layoutHeight,
|
||||
branches: branchList,
|
||||
childIdsByParent,
|
||||
streamStrokePx,
|
||||
portraitSizePx,
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||
|
||||
const artistPlacements = useMemo(
|
||||
() =>
|
||||
buildArtistPlacements(
|
||||
layouts,
|
||||
artistsByMovement,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
streamStrokePx,
|
||||
portraitSizePx
|
||||
),
|
||||
[layouts, artistsByMovement, viewStart, viewEnd, streamStrokePx, portraitSizePx]
|
||||
);
|
||||
|
||||
if (visibleMovements.length === 0) {
|
||||
@@ -48,72 +589,200 @@ export default function MovementBands({ movements, artists, viewStart, viewEnd,
|
||||
);
|
||||
}
|
||||
|
||||
const layoutById = new Map(layouts.map((l) => [l.movement.id, l]));
|
||||
|
||||
return (
|
||||
<div className="movements-container">
|
||||
{visibleMovements.map((movement) => {
|
||||
const left = yearToPercent(Math.max(movement.start_year, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(movement.end_year, viewEnd), viewStart, viewEnd);
|
||||
const width = right - left;
|
||||
if (width <= 0) return null;
|
||||
<div className="movements-flow">
|
||||
<p className="movements-flow-caption">
|
||||
Scroll to zoom · drag to pan · streams blend from movement to movement through history
|
||||
</p>
|
||||
|
||||
const movementArtists = artistsByMovement.get(movement.id) || [];
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}`}
|
||||
style={{
|
||||
['--stream-stroke' as string]: `${streamStrokePx}px`,
|
||||
['--portrait-size' as string]: `${portraitSizePx}px`,
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handlePanStart}
|
||||
>
|
||||
<svg
|
||||
className="movements-flow-svg"
|
||||
viewBox={`0 0 100 ${layoutHeight}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
{branches.map((branch) => (
|
||||
<linearGradient
|
||||
key={`branch-grad-${branch.key}`}
|
||||
id={`branch-grad-${branch.key}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={branch.x1}
|
||||
y1={branch.y1}
|
||||
x2={branch.x2}
|
||||
y2={branch.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={branch.colorFrom} stopOpacity={0} />
|
||||
<stop offset="18%" stopColor={branch.colorFrom} stopOpacity={0.34} />
|
||||
<stop offset="50%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="82%" stopColor={branch.colorTo} stopOpacity={0.34} />
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
|
||||
return (
|
||||
<div key={movement.id} className="movement-row">
|
||||
<div className="movement-label">
|
||||
<span className="movement-name">{movement.name}</span>
|
||||
{movement.era_name && <span className="movement-era">{movement.era_name}</span>}
|
||||
{layouts.map((layout) => {
|
||||
const primaryParent =
|
||||
layout.parentIds.length > 0 ? layoutById.get(layout.parentIds[0]) : null;
|
||||
const hasChildren = (childIdsByParent.get(layout.movement.id)?.length ?? 0) > 0;
|
||||
const parentColor = primaryParent?.movement.color ?? layout.movement.color;
|
||||
|
||||
return (
|
||||
<linearGradient
|
||||
key={`grad-${layout.movement.id}`}
|
||||
id={`stream-grad-${layout.movement.id}`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1={layout.xStart}
|
||||
y1={layout.y}
|
||||
x2={layout.xEnd}
|
||||
y2={layout.yEnd}
|
||||
>
|
||||
{primaryParent ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={parentColor} stopOpacity={0.28} />
|
||||
<stop offset="16%" stopColor={layout.movement.color} stopOpacity={0.34} />
|
||||
<stop offset="32%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.start_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
<stop offset="50%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<stop offset="68%" stopColor={layout.movement.color} stopOpacity={0.38} />
|
||||
<stop offset="84%" stopColor={layout.movement.color} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={layout.movement.color} stopOpacity={0} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={layout.movement.color}
|
||||
stopOpacity={layout.movement.end_definite ? 0.28 : 0.08}
|
||||
/>
|
||||
)}
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{branches.map((branch) => (
|
||||
<path
|
||||
key={branch.key}
|
||||
d={branch.d}
|
||||
className="movement-branch"
|
||||
stroke={`url(#branch-grad-${branch.key})`}
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`stream-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-fill"
|
||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{layouts.map((layout) => {
|
||||
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
|
||||
return (
|
||||
<path
|
||||
key={`core-${layout.movement.id}`}
|
||||
d={d}
|
||||
className="movement-stream-core"
|
||||
stroke={layout.movement.color}
|
||||
fill="none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="movements-flow-labels">
|
||||
{layouts.map((layout) => (
|
||||
<div
|
||||
key={`label-${layout.movement.id}`}
|
||||
className="movement-flow-label"
|
||||
style={{
|
||||
left: `${layout.xStart}%`,
|
||||
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span className="movement-name">{layout.movement.name}</span>
|
||||
{layout.movement.era_name && (
|
||||
<span className="movement-era">{layout.movement.era_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="movement-band-track">
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="movements-flow-artists">
|
||||
{artistPlacements.map(({ layout, artist, lineLeft, lineWidth, portraitLeft, y, lane, color }) => {
|
||||
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
|
||||
const deathLabel = artist.death_year != null ? artist.death_year : '?';
|
||||
const artistKey = `${layout.movement.id}-${artist.id}`;
|
||||
const isHovered = hoveredArtistKey === artistKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="movement-band"
|
||||
key={artistKey}
|
||||
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
borderLeft: movement.start_definite
|
||||
? `3px solid ${movement.color}`
|
||||
: 'none',
|
||||
borderRight: movement.end_definite
|
||||
? `3px solid ${movement.color}`
|
||||
: 'none',
|
||||
background: `linear-gradient(90deg,
|
||||
${movement.start_definite ? movement.color : 'transparent'} 0%,
|
||||
${movement.color}33 10%,
|
||||
${movement.color}33 90%,
|
||||
${movement.end_definite ? movement.color : 'transparent'} 100%)`,
|
||||
left: `${lineLeft}%`,
|
||||
width: `${lineWidth}%`,
|
||||
top: `${(y / layoutHeight) * 100}%`,
|
||||
zIndex: isHovered ? 12 : 4 + lane,
|
||||
['--lifespan-color' as string]: color,
|
||||
}}
|
||||
>
|
||||
{movementArtists.map((artist) => {
|
||||
const timelineYear = artistTimelineYear(artist);
|
||||
const artistLeft = yearToPercent(timelineYear, viewStart, viewEnd);
|
||||
if (artistLeft < left || artistLeft > right) return null;
|
||||
|
||||
const relLeft = ((artistLeft - left) / width) * 100;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-portrait"
|
||||
style={{ left: `${relLeft}%` }}
|
||||
onClick={() => onArtistClick(artist.id)}
|
||||
title={`${artist.name} (${artist.birth_year}–${artist.death_year})`}
|
||||
>
|
||||
<img
|
||||
src={imageUrl(artist.portrait_path)}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="artist-name">{artist.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
|
||||
<button
|
||||
type="button"
|
||||
className="artist-portrait"
|
||||
style={{
|
||||
left: `${portraitLeft}%`,
|
||||
borderColor: color,
|
||||
}}
|
||||
onClick={() => onArtistClick(artist.id)}
|
||||
onMouseEnter={() => setHoveredArtistKey(artistKey)}
|
||||
onMouseLeave={() => setHoveredArtistKey(null)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
title={`${artist.name} (${birthLabel}–${deathLabel}) · ${layout.movement.name}`}
|
||||
>
|
||||
<img
|
||||
src={imageUrl(artist.portrait_path)}
|
||||
alt={artist.name}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
|
||||
}}
|
||||
/>
|
||||
<span className="artist-name">{artist.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
height: 88px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(201, 169, 110, 0.3);
|
||||
@@ -67,6 +67,23 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.era-block:hover {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.era-block:focus-visible {
|
||||
outline: 2px solid #c9a96e;
|
||||
outline-offset: -2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.era-label {
|
||||
@@ -79,6 +96,102 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-events {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.historical-event {
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.historical-event-point {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.historical-event-point .event-marker-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 210, 120, 0.95) 0%,
|
||||
rgba(255, 180, 90, 0.75) 100%
|
||||
);
|
||||
box-shadow: 0 0 6px rgba(255, 190, 100, 0.45);
|
||||
}
|
||||
|
||||
.historical-event-point::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: rgba(255, 215, 130, 0.95);
|
||||
border: 1px solid rgba(255, 240, 200, 0.8);
|
||||
box-shadow: 0 0 4px rgba(255, 200, 100, 0.5);
|
||||
}
|
||||
|
||||
.historical-event-span {
|
||||
top: 20px;
|
||||
bottom: 24px;
|
||||
background: rgba(180, 70, 55, 0.22);
|
||||
border-left: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-right: 2px solid rgba(255, 150, 110, 0.65);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.historical-event-span:hover,
|
||||
.historical-event-point:hover .event-marker-line {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.historical-event:focus-visible {
|
||||
outline: 2px solid #e8c878;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.event-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 120px;
|
||||
padding: 1px 5px;
|
||||
font-family: 'Georgia', serif;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 235, 200, 0.95);
|
||||
background: rgba(20, 16, 28, 0.72);
|
||||
border: 1px solid rgba(255, 200, 120, 0.35);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.historical-event-span .event-label {
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.timeline-ticks {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { HistoricalEra } from '../types';
|
||||
import {
|
||||
HISTORICAL_EVENTS,
|
||||
eventEndYear,
|
||||
eventInView,
|
||||
type HistoricalEvent,
|
||||
} from '../data/historical-events';
|
||||
import './Timeline.css';
|
||||
|
||||
interface Props {
|
||||
@@ -101,6 +107,68 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
|
||||
const resetView = () => onViewChange(absoluteMin, absoluteMax);
|
||||
|
||||
const zoomToEra = useCallback(
|
||||
(era: HistoricalEra, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const eraSpan = era.end_year - era.start_year;
|
||||
const padding = Math.max(5, Math.round(eraSpan * 0.03));
|
||||
let start = era.start_year - padding;
|
||||
let end = era.end_year + padding;
|
||||
|
||||
if (start < absoluteMin) start = absoluteMin;
|
||||
if (end > absoluteMax) end = absoluteMax;
|
||||
if (end - start < 10) {
|
||||
const center = (era.start_year + era.end_year) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
end = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
|
||||
onViewChange(Math.round(start), Math.round(end));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const zoomToEvent = useCallback(
|
||||
(event: HistoricalEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const end = eventEndYear(event);
|
||||
const eventSpan = Math.max(end - event.startYear, 1);
|
||||
const padding = Math.max(8, Math.round(eventSpan * 0.2));
|
||||
let start = event.startYear - padding;
|
||||
let endView = end + padding;
|
||||
|
||||
if (start < absoluteMin) start = absoluteMin;
|
||||
if (endView > absoluteMax) endView = absoluteMax;
|
||||
if (endView - start < 10) {
|
||||
const center = (event.startYear + end) / 2;
|
||||
start = Math.max(absoluteMin, Math.round(center - 5));
|
||||
endView = Math.min(absoluteMax, Math.round(center + 5));
|
||||
}
|
||||
|
||||
onViewChange(Math.round(start), Math.round(endView));
|
||||
},
|
||||
[absoluteMin, absoluteMax, onViewChange]
|
||||
);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const inView = HISTORICAL_EVENTS.filter((event) => eventInView(event, viewStart, viewEnd));
|
||||
const minLabelGapYears =
|
||||
span > 200 ? 40 : span > 80 ? 18 : span > 30 ? 10 : span > 12 ? 5 : 2;
|
||||
|
||||
let lastLabelYear = -Infinity;
|
||||
return inView.map((event) => {
|
||||
const end = eventEndYear(event);
|
||||
const labelAnchor = event.endYear ? (event.startYear + end) / 2 : event.startYear;
|
||||
const showLabel = labelAnchor - lastLabelYear >= minLabelGapYears;
|
||||
if (showLabel) lastLabelYear = labelAnchor;
|
||||
return { event, showLabel };
|
||||
});
|
||||
}, [viewStart, viewEnd, span]);
|
||||
|
||||
return (
|
||||
<div className="timeline-wrapper">
|
||||
<div className="timeline-controls">
|
||||
@@ -124,8 +192,9 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
const right = yearToPercent(Math.min(era.end_year, viewEnd), viewStart, viewEnd);
|
||||
if (right <= 0 || left >= 100) return null;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={era.id}
|
||||
type="button"
|
||||
className="era-block"
|
||||
style={{
|
||||
left: `${Math.max(0, left)}%`,
|
||||
@@ -139,10 +208,63 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
${era.end_definite ? 'var(--era-color)' : 'transparent'} 100%)`,
|
||||
['--era-color' as string]: getEraColor(era.name),
|
||||
}}
|
||||
title={era.description}
|
||||
title={`${era.name}: ${formatYear(era.start_year)} – ${formatYear(era.end_year)}${era.description ? ` · ${era.description}` : ''}`}
|
||||
onClick={(e) => zoomToEra(era, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="era-label">{era.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="timeline-events" aria-hidden={false}>
|
||||
{visibleEvents.map(({ event, showLabel }) => {
|
||||
const end = eventEndYear(event);
|
||||
const isSpan = event.endYear != null && event.endYear !== event.startYear;
|
||||
|
||||
if (isSpan) {
|
||||
const left = yearToPercent(Math.max(event.startYear, viewStart), viewStart, viewEnd);
|
||||
const right = yearToPercent(Math.min(end, viewEnd), viewStart, viewEnd);
|
||||
if (right <= 0 || left >= 100) return null;
|
||||
const width = Math.min(100, right) - Math.max(0, left);
|
||||
const label = showLabel ? event.shortLabel ?? event.name : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="historical-event historical-event-span"
|
||||
style={{
|
||||
left: `${Math.max(0, left)}%`,
|
||||
width: `${width}%`,
|
||||
}}
|
||||
title={`${event.name} (${formatYear(event.startYear)} – ${formatYear(end)})`}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label && <span className="event-label">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startYear < viewStart || event.startYear > viewEnd) return null;
|
||||
const left = yearToPercent(event.startYear, viewStart, viewEnd);
|
||||
const label = showLabel ? event.shortLabel ?? event.name : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="historical-event historical-event-point"
|
||||
style={{ left: `${left}%` }}
|
||||
title={`${event.name} (${formatYear(event.startYear)})`}
|
||||
onClick={(e) => zoomToEvent(event, e)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="event-marker-line" />
|
||||
{label && <span className="event-label">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -172,7 +294,7 @@ export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absol
|
||||
</div>
|
||||
|
||||
<p className="timeline-hint">
|
||||
Scroll to zoom · Drag to pan · Use +/− buttons to zoom into a historical period
|
||||
Click an era or event to zoom · Scroll to zoom · Drag to pan
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Major world events shown as markers on the timeline (frontend curation). */
|
||||
export interface HistoricalEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
startYear: number;
|
||||
/** Omit for a single-year marker. */
|
||||
endYear?: number;
|
||||
/** Shorter label when the view is crowded. */
|
||||
shortLabel?: string;
|
||||
}
|
||||
|
||||
export const HISTORICAL_EVENTS: readonly HistoricalEvent[] = [
|
||||
{ id: 'fall-rome', name: 'Fall of Rome', startYear: 476 },
|
||||
{ id: 'black-death', name: 'Black Death', startYear: 1347 },
|
||||
{ id: 'printing-press', name: 'Printing press', startYear: 1450 },
|
||||
{ id: 'reformation', name: 'Protestant Reformation', startYear: 1517 },
|
||||
{ id: 'columbus', name: 'Columbus reaches the Americas', startYear: 1492 },
|
||||
{ id: 'american-revolution', name: 'American Revolution', startYear: 1776 },
|
||||
{ id: 'french-revolution', name: 'French Revolution', startYear: 1789 },
|
||||
{ id: 'waterloo', name: 'Battle of Waterloo', startYear: 1815 },
|
||||
{ id: 'revolutions-1848', name: 'Revolutions of 1848', startYear: 1848 },
|
||||
{ id: 'german-unification', name: 'Unification of Germany', startYear: 1871 },
|
||||
{ id: 'ww1', name: 'World War I', startYear: 1914, endYear: 1918, shortLabel: 'WWI' },
|
||||
{ id: 'russian-revolution', name: 'Russian Revolution', startYear: 1917 },
|
||||
{ id: 'ww2', name: 'World War II', startYear: 1939, endYear: 1945, shortLabel: 'WWII' },
|
||||
{ id: 'berlin-wall', name: 'Fall of Berlin Wall', startYear: 1989 },
|
||||
];
|
||||
|
||||
export function eventEndYear(event: HistoricalEvent): number {
|
||||
return event.endYear ?? event.startYear;
|
||||
}
|
||||
|
||||
export function eventInView(event: HistoricalEvent, viewStart: number, viewEnd: number): boolean {
|
||||
return eventEndYear(event) >= viewStart && event.startYear <= viewEnd;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Documented art-historical movement lineage (predecessor → successor).
|
||||
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
|
||||
*
|
||||
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
|
||||
*/
|
||||
export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: string]> = [
|
||||
// Classical & medieval
|
||||
['Ancient Greek & Roman', 'Byzantine'],
|
||||
['Byzantine', 'Gothic'],
|
||||
|
||||
// Renaissance chain
|
||||
['Gothic', 'Early Renaissance'],
|
||||
['Early Renaissance', 'High Renaissance'],
|
||||
['Northern Renaissance', 'Early Renaissance'],
|
||||
['High Renaissance', 'Mannerism'],
|
||||
['Mannerism', 'Baroque'],
|
||||
|
||||
// 17th–19th century
|
||||
['Baroque', 'Rococo'],
|
||||
['Rococo', 'Neoclassicism'],
|
||||
['Neoclassicism', 'Romanticism'],
|
||||
['Romanticism', 'Realism'],
|
||||
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
|
||||
['Realism', 'Impressionism'],
|
||||
|
||||
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
|
||||
['Impressionism', 'Post-Impressionism'],
|
||||
['Post-Impressionism', 'Symbolism'],
|
||||
['Post-Impressionism', 'Fauvism'],
|
||||
['Post-Impressionism', 'Expressionism'],
|
||||
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
|
||||
['Symbolism', 'Art Nouveau'],
|
||||
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
|
||||
['Cubism', 'Futurism'],
|
||||
['Cubism', 'Suprematism'],
|
||||
['Cubism', 'Constructivism'],
|
||||
['Expressionism', 'Abstract Expressionism'],
|
||||
|
||||
// Avant-garde 20th century
|
||||
['Dada', 'Surrealism'],
|
||||
['Surrealism', 'Abstract Expressionism'],
|
||||
['Abstract Expressionism', 'Pop Art'],
|
||||
];
|
||||
|
||||
export function lineageParentsByName(childName: string): string[] {
|
||||
return MOVEMENT_LINEAGE.filter(([, child]) => child === childName).map(([parent]) => parent);
|
||||
}
|
||||
|
||||
export function lineageChildrenByName(parentName: string): string[] {
|
||||
return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
|
||||
}
|
||||
|
||||
.home-movements-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gallery-session-suspended {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -21,6 +31,7 @@
|
||||
.site-header {
|
||||
text-align: center;
|
||||
padding: 24px 16px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
@@ -47,6 +58,10 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-page > .timeline-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 16px;
|
||||
padding: 12px 16px;
|
||||
|
||||
@@ -202,7 +202,7 @@ export default function HomePage() {
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Explore the flowing connections of art history</p>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
</header>
|
||||
|
||||
<Timeline
|
||||
@@ -217,15 +217,20 @@ export default function HomePage() {
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading art history...</div>
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Shared timeline zoom/pan math for Timeline and MovementBands. */
|
||||
export function zoomTimelineView(
|
||||
clientX: number,
|
||||
rectLeft: number,
|
||||
rectWidth: number,
|
||||
deltaY: number,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
absoluteMin: number,
|
||||
absoluteMax: number
|
||||
): { start: number; end: number } {
|
||||
const span = viewEnd - viewStart;
|
||||
const ratio = (clientX - rectLeft) / rectWidth;
|
||||
const centerYear = viewStart + ratio * span;
|
||||
const factor = deltaY > 0 ? 1.15 : 0.85;
|
||||
const newSpan = Math.max(10, Math.min(absoluteMax - absoluteMin, span * factor));
|
||||
let newStart = centerYear - ratio * newSpan;
|
||||
let newEnd = centerYear + (1 - ratio) * newSpan;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||
}
|
||||
|
||||
export function panTimelineView(
|
||||
deltaX: number,
|
||||
rectWidth: number,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
absoluteMin: number,
|
||||
absoluteMax: number
|
||||
): { start: number; end: number } {
|
||||
const span = viewEnd - viewStart;
|
||||
const yearDelta = (deltaX / rectWidth) * span;
|
||||
let newStart = viewStart - yearDelta;
|
||||
let newEnd = viewEnd - yearDelta;
|
||||
if (newStart < absoluteMin) {
|
||||
newEnd += absoluteMin - newStart;
|
||||
newStart = absoluteMin;
|
||||
}
|
||||
if (newEnd > absoluteMax) {
|
||||
newStart -= newEnd - absoluteMax;
|
||||
newEnd = absoluteMax;
|
||||
}
|
||||
return { start: Math.round(newStart), end: Math.round(newEnd) };
|
||||
}
|
||||
Reference in New Issue
Block a user