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:
Danila Khodjaef
2026-06-20 00:21:31 +03:00
co-authored by Cursor
parent 35c337253d
commit 0ece1195fa
1301 changed files with 1412 additions and 140 deletions
+163 -49
View File
@@ -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;
}
}
+735 -66
View File
@@ -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>
);
}
+114 -1
View File
@@ -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;
+127 -5
View File
@@ -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>
);
+35
View File
@@ -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;
}
+52
View File
@@ -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'],
// 17th19th 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);
}
+16 -1
View File
@@ -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;
+14 -9
View File
@@ -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>
)}
+51
View File
@@ -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) };
}