Improve movement ribbon layout with collision-aware labels and hover highlight.

Add FAC operator cheat sheet and link it from README and setup docs.
This commit is contained in:
Danila Khodjaef
2026-07-05 23:07:44 +03:00
parent 2edf577faf
commit aa31a2aa6e
5 changed files with 712 additions and 66 deletions
+39 -3
View File
@@ -112,12 +112,14 @@
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-fill {
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
@@ -127,6 +129,33 @@
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
}
@keyframes stream-shimmer {
@@ -201,16 +230,23 @@
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
z-index: 6;
}
.movement-flow-label {
position: absolute;
transform: translate(-4px, -100%);
max-width: 160px;
padding: 2px 6px;
pointer-events: none;
z-index: 3;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
}
.movement-flow-label-btn {
+380 -62
View File
@@ -42,6 +42,8 @@ interface BranchSegment {
const MAX_STREAM_STROKE_PX = 54;
const MIN_STREAM_STROKE_PX = 28;
const LANE_GAP_PX = 10;
const DEPTH_GAP_PX = 14;
const CANVAS_TOP_PAD = 38;
const CANVAS_BOTTOM_PAD = 24;
const DEFAULT_CANVAS_HEIGHT = 360;
@@ -327,11 +329,50 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map<number, numb
return depth;
}
function streamPath(xStart: number, xEnd: number, yStart: number, yEnd: number): string {
/** Pack movements into lanes — only concurrent (overlapping) spans need separate lanes. */
function assignTemporalLanes(
group: ArtMovement[],
viewStart: number,
viewEnd: number
): Map<number, number> {
const spans = group
.map((m) => ({
id: m.id,
start: Math.max(m.start_year, viewStart),
end: Math.min(m.end_year, viewEnd),
}))
.filter((s) => s.end > s.start)
.sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds: number[] = [];
const laneById = new Map<number, number>();
for (const span of spans) {
let lane = laneEnds.findIndex((endYear) => endYear <= span.start);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(span.end);
} else {
laneEnds[lane] = span.end;
}
laneById.set(span.id, lane);
}
return laneById;
}
function streamPath(
xStart: number,
xEnd: number,
yStart: number,
yEnd: number,
curveOffset = 14
): string {
const width = Math.max(xEnd - xStart, 0.5);
const bulge = Math.min(curveOffset, width * 0.08);
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}`;
return `M ${xStart} ${yStart} C ${c1x} ${yStart - bulge}, ${c2x} ${yEnd + bulge}, ${xEnd} ${yEnd}`;
}
function yOnStream(layout: MovementLayout, x: number): number {
@@ -359,6 +400,240 @@ function branchOriginOnParent(parent: MovementLayout, childIndex: number, childC
return { x, y: yOnStream(parent, x) };
}
interface LabelPlacement {
layout: MovementLayout;
leftPct: number;
topPct: number;
above: boolean;
}
interface PixelRect {
left: number;
top: number;
right: number;
bottom: number;
}
const LABEL_MAX_WIDTH_PX = 160;
const LABEL_GAP_PX = 8;
const LABEL_PORTRAIT_PAD_PX = 6;
const LABEL_MIN_SEPARATION_PX = 4;
function estimateLabelWidthPx(name: string): number {
const textWidth = name.length * 7.2 + 14;
return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth));
}
function labelHeightPx(hasEra: boolean): number {
return hasEra ? 36 : 22;
}
function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): boolean {
return !(
a.right + pad <= b.left ||
a.left - pad >= b.right ||
a.bottom + pad <= b.top ||
a.top - pad >= b.bottom
);
}
function portraitObstacleRect(
portraitX: number,
y: number,
portraitSizePx: number,
layoutHeight: number,
canvasWidth: number,
canvasHeight: number
): PixelRect {
const cx = (portraitX / 100) * canvasWidth;
const cy = (y / layoutHeight) * canvasHeight;
const half = portraitSizePx / 2 + LABEL_PORTRAIT_PAD_PX;
return { left: cx - half, top: cy - half, right: cx + half, bottom: cy + half };
}
function streamObstacleRect(
streamY: number,
streamStrokePx: number,
layoutHeight: number,
canvasHeight: number,
leftPx: number,
widthPx: number
): PixelRect {
const cy = (streamY / layoutHeight) * canvasHeight;
const half = streamStrokePx / 2 + 2;
return {
left: leftPx,
top: cy - half,
right: leftPx + widthPx,
bottom: cy + half,
};
}
function labelObstacleRect(
leftPct: number,
streamY: number,
above: boolean,
widthPx: number,
heightPx: number,
streamStrokePx: number,
layoutHeight: number,
canvasWidth: number,
canvasHeight: number
): PixelRect {
const anchorX = (leftPct / 100) * canvasWidth - 4;
const streamYPx = (streamY / layoutHeight) * canvasHeight;
if (above) {
const bottom = streamYPx - LABEL_GAP_PX;
return { left: anchorX, top: bottom - heightPx, right: anchorX + widthPx, bottom };
}
const top = streamYPx + streamStrokePx / 2 + LABEL_GAP_PX;
return { left: anchorX, top, right: anchorX + widthPx, bottom: top + heightPx };
}
function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number): number {
const halfWidthPct = ((widthPx / canvasWidth) * 100) / 2;
return Math.min(Math.max(leftPct, halfWidthPct + 0.5), 100 - halfWidthPct - 0.5);
}
function labelAnchorTopPct(
streamY: number,
above: boolean,
streamStrokePx: number,
layoutHeight: number
): number {
const gap = above ? LABEL_GAP_PX : streamStrokePx / 2 + LABEL_GAP_PX;
const y = above ? streamY - gap : streamY + gap;
return (y / layoutHeight) * 100;
}
function buildLabelPlacements(
layouts: MovementLayout[],
artistPlacements: ArtistPlacement[],
layoutHeight: number,
canvasWidth: number,
canvasHeight: number,
streamStrokePx: number,
portraitSizePx: number
): LabelPlacement[] {
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
const portraitObstacles = artistPlacements.map((p) =>
portraitObstacleRect(p.portraitX, p.y, portraitSizePx, layoutHeight, canvasWidth, canvasHeight)
);
const placed: LabelPlacement[] = [];
const placedLabelRects: PixelRect[] = [];
const sortedLayouts = [...layouts].sort((a, b) => a.y - b.y || a.xStart - b.xStart);
for (const layout of sortedLayouts) {
const hasEra = !!layout.movement.era_name;
const widthPx = estimateLabelWidthPx(layout.movement.name);
const heightPx = labelHeightPx(hasEra);
const span = Math.max(layout.xEnd - layout.xStart, 0.5);
const xCandidates = new Set<number>();
for (const t of [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]) {
xCandidates.add(layout.xStart + span * t);
}
const movementPortraits = artistPlacements
.filter((p) => p.layout.movement.id === layout.movement.id)
.sort((a, b) => a.portraitX - b.portraitX);
if (movementPortraits.length === 0) {
xCandidates.add(layout.xStart + span * 0.5);
} else {
const first = movementPortraits[0].portraitX;
const last = movementPortraits[movementPortraits.length - 1].portraitX;
if (first - layout.xStart > 4) xCandidates.add(layout.xStart + (first - layout.xStart) * 0.45);
if (layout.xEnd - last > 4) xCandidates.add(last + (layout.xEnd - last) * 0.55);
for (let i = 0; i < movementPortraits.length - 1; i++) {
xCandidates.add((movementPortraits[i].portraitX + movementPortraits[i + 1].portraitX) / 2);
}
}
const orderedX = [...xCandidates].sort(
(a, b) => Math.abs(a - (layout.xStart + span * 0.5)) - Math.abs(b - (layout.xStart + span * 0.5))
);
let chosen: LabelPlacement | null = null;
for (const above of [true, false] as const) {
for (const rawX of orderedX) {
const leftPct = clampLabelLeftPct(rawX, widthPx, canvasWidth);
const streamY = yOnStream(layout, leftPct);
const rect = labelObstacleRect(
leftPct,
streamY,
above,
widthPx,
heightPx,
streamStrokePx,
layoutHeight,
canvasWidth,
canvasHeight
);
const streamRect = streamObstacleRect(
streamY,
streamStrokePx,
layoutHeight,
canvasHeight,
rect.left,
widthPx
);
const blocked =
portraitObstacles.some((o) => rectsOverlap(rect, o)) ||
placedLabelRects.some((o) => rectsOverlap(rect, o)) ||
(above && rectsOverlap(rect, streamRect));
if (!blocked) {
chosen = {
layout,
leftPct,
topPct: labelAnchorTopPct(streamY, above, streamStrokePx, layoutHeight),
above,
};
placedLabelRects.push(rect);
break;
}
}
if (chosen) break;
}
if (!chosen) {
const stackIndex = placed.filter((p) => p.layout.depth === layout.depth).length;
const streamY = layout.y;
const liftPx = LABEL_GAP_PX + stackIndex * (heightPx + 6);
const leftPct = clampLabelLeftPct(layout.xStart + span * 0.08, widthPx, canvasWidth);
chosen = {
layout,
leftPct,
topPct: ((streamY - liftPx) / layoutHeight) * 100,
above: true,
};
placedLabelRects.push(
labelObstacleRect(
leftPct,
streamY,
true,
widthPx,
heightPx,
streamStrokePx,
layoutHeight,
canvasWidth,
canvasHeight
)
);
}
placed.push(chosen);
}
return placed;
}
function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
const x = (layout.xStart + layout.xEnd) / 2;
return { x, y: yOnStream(layout, x) };
@@ -383,6 +658,7 @@ export default function MovementBands({
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
const [canvasWidth, setCanvasWidth] = useState(800);
const [hoveredArtistKey, setHoveredArtistKey] = useState<string | null>(null);
const [hoveredMovementId, setHoveredMovementId] = useState<number | null>(null);
const panStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
const interactionTimer = useRef<number | null>(null);
const viewRef = useRef({ viewStart, viewEnd });
@@ -516,7 +792,7 @@ export default function MovementBands({
};
}, [visibleMovements.length]);
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx } =
const { layouts, layoutHeight, branches, childIdsByParent, streamStrokePx, portraitSizePx, streamCurveOffset } =
useMemo(() => {
if (visibleMovements.length === 0) {
return {
@@ -526,6 +802,7 @@ export default function MovementBands({
childIdsByParent: new Map<number, number[]>(),
streamStrokePx: MAX_STREAM_STROKE_PX,
portraitSizePx: 52,
streamCurveOffset: 14,
};
}
@@ -535,8 +812,6 @@ export default function MovementBands({
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) {
@@ -546,25 +821,43 @@ export default function MovementBands({
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);
const laneIndex = new Map<number, number>();
const maxLanesByDepth = new Map<number, number>();
for (const [depth, group] of byDepth.entries()) {
const temporalLanes = assignTemporalLanes(group, viewStart, viewEnd);
let maxLane = 0;
for (const movement of group) {
const lane = temporalLanes.get(movement.id) ?? 0;
laneIndex.set(movement.id, lane);
maxLane = Math.max(maxLane, lane + 1);
}
maxLanesByDepth.set(depth, Math.max(1, maxLane));
}
const depthCount = maxDepth + 1;
const depthGaps = Math.max(0, depthCount - 1) * DEPTH_GAP_PX;
const totalLaneSlots = [...maxLanesByDepth.values()].reduce((sum, n) => sum + n, 0);
const minLaneStep = MIN_STREAM_STROKE_PX + LANE_GAP_PX;
const laneStep = Math.max(minLaneStep, (usableHeight - depthGaps) / Math.max(1, totalLaneSlots));
let streamStrokePx = Math.min(MAX_STREAM_STROKE_PX, laneStep - LANE_GAP_PX);
streamStrokePx = Math.max(MIN_STREAM_STROKE_PX, streamStrokePx);
const portraitSizePx = Math.max(28, Math.min(52, streamStrokePx * 0.96));
const driftScale = rowStep < 72 ? 0 : rowStep < 88 ? 0.12 : 0.3;
const streamCurveOffset = Math.min(12, Math.max(2, (laneStep - streamStrokePx) * 0.35));
const depthBaseY = new Map<number, number>();
let yCursor = CANVAS_TOP_PAD;
for (let depth = 0; depth <= maxDepth; depth++) {
depthBaseY.set(depth, yCursor);
const bandLanes = maxLanesByDepth.get(depth) ?? 1;
yCursor += bandLanes * laneStep;
if (depth < maxDepth) yCursor += DEPTH_GAP_PX;
}
const layoutById = new Map<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);
@@ -572,17 +865,11 @@ export default function MovementBands({
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 lanesAtDepth = maxLanesByDepth.get(depth) ?? 1;
const bandTop = depthBaseY.get(depth) ?? CANVAS_TOP_PAD;
const y = bandTop + lane * laneStep + laneStep / 2;
const allowDrift = lanesAtDepth === 1 && laneStep >= 80;
const yEnd = y + (allowDrift ? organicDrift(movement.id + 1000) * 0.22 : 0);
const parentIds = lineageParents.get(movement.id) || [];
layoutById.set(movement.id, {
@@ -642,29 +929,49 @@ export default function MovementBands({
childIdsByParent,
streamStrokePx,
portraitSizePx,
streamCurveOffset,
};
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
const artistPlacementsForLabels = useMemo(
() =>
buildArtistPlacements(
layouts,
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth
),
[layouts, artistsByMovement, viewStart, viewEnd, portraitSizePx, canvasWidth]
);
const artistPlacements = useMemo(() => {
if (interacting || panning) return [];
return buildArtistPlacements(
return artistPlacementsForLabels;
}, [artistPlacementsForLabels, interacting, panning]);
const labelPlacements = useMemo(
() =>
buildLabelPlacements(
layouts,
artistPlacementsForLabels,
layoutHeight,
canvasWidth,
canvasHeight,
streamStrokePx,
portraitSizePx
),
[
layouts,
artistsByMovement,
viewStart,
viewEnd,
artistPlacementsForLabels,
layoutHeight,
canvasWidth,
canvasHeight,
streamStrokePx,
portraitSizePx,
canvasWidth
);
}, [
layouts,
artistsByMovement,
viewStart,
viewEnd,
portraitSizePx,
canvasWidth,
interacting,
panning,
]);
]
);
const hoveredPlacement = useMemo(() => {
if (!hoveredArtistKey) return null;
@@ -694,11 +1001,12 @@ export default function MovementBands({
<div
ref={canvasRef}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}`}
className={`movements-flow-canvas${panning ? ' movements-flow-panning' : ''}${fastGraphics ? ' movements-flow-interacting' : ''}${hoveredMovementId != null ? ' movements-flow-movement-hover' : ''}`}
style={{
['--stream-stroke' as string]: `${streamStrokePx}px`,
['--portrait-size' as string]: `${portraitSizePx}px`,
}}
data-movement-hover={hoveredMovementId ?? undefined}
onMouseDown={handlePanStart}
>
<svg
@@ -788,7 +1096,7 @@ export default function MovementBands({
/>
))}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
return (
<path
key={`stream-${layout.movement.id}`}
@@ -802,23 +1110,30 @@ export default function MovementBands({
</>
) : (
<>
{branches.map((branch) => (
<path
key={branch.key}
d={branch.d}
className="movement-branch"
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
))}
{branches.map((branch) => {
const [fromId, toId] = branch.key.split('-').map(Number);
const branchHighlighted =
hoveredMovementId != null &&
(fromId === hoveredMovementId || toId === hoveredMovementId);
return (
<path
key={branch.key}
d={branch.d}
className={`movement-branch${branchHighlighted ? ' movement-branch-highlighted' : ''}`}
stroke={`url(#branch-grad-${branch.key})`}
fill="none"
/>
);
})}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
const streamHighlighted = hoveredMovementId === layout.movement.id;
return (
<path
key={`stream-${layout.movement.id}`}
d={d}
className="movement-stream-fill"
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={`url(#stream-grad-${layout.movement.id})`}
fill="none"
/>
@@ -826,12 +1141,13 @@ export default function MovementBands({
})}
{layouts.map((layout) => {
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd);
const d = streamPath(layout.xStart, layout.xEnd, layout.y, layout.yEnd, streamCurveOffset);
const streamHighlighted = hoveredMovementId === layout.movement.id;
return (
<path
key={`core-${layout.movement.id}`}
d={d}
className="movement-stream-core"
className={`movement-stream-core${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
stroke={layout.movement.color}
fill="none"
/>
@@ -872,17 +1188,19 @@ export default function MovementBands({
{!fastGraphics && (
<>
<div className="movements-flow-labels">
{layouts.map((layout) => (
{labelPlacements.map(({ layout, leftPct, topPct, above }) => (
<button
key={`label-${layout.movement.id}`}
type="button"
className="movement-flow-label movement-flow-label-btn"
className={`movement-flow-label movement-flow-label-btn${above ? ' movement-flow-label-above' : ' movement-flow-label-below'}`}
style={{
left: `${layout.xStart}%`,
top: `${((layout.y - 32) / layoutHeight) * 100}%`,
left: `${leftPct}%`,
top: `${topPct}%`,
}}
title={`Open ${layout.movement.name} gallery hall`}
onClick={() => onMovementClick?.(layout.movement.id)}
onMouseEnter={() => setHoveredMovementId(layout.movement.id)}
onMouseLeave={() => setHoveredMovementId(null)}
onMouseDown={(e) => e.stopPropagation()}
>
<span className="movement-name">{layout.movement.name}</span>