Improve movement label placement and lane corridor clearing
Label priority: center, right-end inside, overflow right/left (prefer side with fewer transition-line overlaps), adjacent gap, hover-only. Exclude own portraits from collision. Min stream height 25px. Corridor clearing: 20 passes, prefer above, re-attract after move. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8111cd0163
commit
ad1572b3aa
@@ -241,16 +241,17 @@ Major world events appear on the era bar as pin markers (single years) or shaded
|
||||
|
||||
### Movement flow
|
||||
|
||||
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) from its start year to its end year.
|
||||
Each visible movement is drawn as a **portrait-width curved stream** (~54 px stroke, compressed when many rows are visible) in a **solid vivid colour** from its start year to its end year.
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|----------------|
|
||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
||||
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). A follow-up pass (`refineLanesForLineageCorridors`) pulls linked parent/child movements onto nearby or shared lanes when years allow, then displaces unrelated streams out of thick lineage branch corridors |
|
||||
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
|
||||
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
|
||||
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
|
||||
| Filtering | A movement is drawn when its **span overlaps** the visible year range **and** it has at least one catalogued artist — artists whose lifespan falls outside the window still keep their movement visible (their portraits simply do not render). Filtered client-side after initial load |
|
||||
| Viewport layout | Row height and stream width scale from measured canvas size so every visible movement row fits in the remaining screen space |
|
||||
| Name labels | On-band **movement name** only. Label placement priority: **(1)** center of movement if label fits and no portrait collision; **(2)** right end inside band borders; **(3)** overflow right or left (up to 100% outside the band border), choosing the side with less transition-line overlap; **(4)** adjacent free gap before/after the band; **(5)** hidden until hover. Collision checks exclude the movement's own portraits (labels may overlap their own band's artists). Movements with overflow labels are packed onto **exclusive horizontal lanes** before vertical stacking so they never share a row with other movements. Minimum movement height = 125% of label height |
|
||||
|
||||
### Artists on movement streams
|
||||
|
||||
@@ -273,7 +274,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
|
||||
| Click portrait | Open artist biography |
|
||||
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
|
||||
|
||||
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full gradients and portraits return when you stop.
|
||||
**Zoom/pan performance:** The movement canvas uses a **capture-phase** wheel listener so scroll zoom works even when the cursor is over a portrait. While scrolling or dragging, a short **interaction mode** (~120 ms after the last input) draws simplified solid SVG strokes and temporarily hides portrait/label DOM so zoom stays responsive; full stream styling and portraits return when you stop.
|
||||
|
||||
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
|
||||
|
||||
|
||||
@@ -287,6 +287,11 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.movement-flow-label-hover-reveal {
|
||||
z-index: 8;
|
||||
box-shadow: 0 0 0 1px rgba(255, 220, 160, 0.4), 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.movement-name {
|
||||
display: block;
|
||||
font-family: 'Georgia', serif;
|
||||
|
||||
@@ -32,8 +32,8 @@ interface MovementLayout {
|
||||
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
||||
strokePx: number;
|
||||
portraitSizePx: number;
|
||||
/** Saturated display color for the dark flow canvas. */
|
||||
displayColor: string;
|
||||
/** True when the on-band name label is wider than the visible movement span. */
|
||||
labelFitsInBand: boolean;
|
||||
}
|
||||
|
||||
interface BranchSegment {
|
||||
@@ -51,7 +51,7 @@ interface BranchSegment {
|
||||
const MAX_STREAM_STROKE_PX = 54;
|
||||
const MIN_STREAM_STROKE_PX = 28;
|
||||
/** When many lanes must fit, stroke may shrink below MIN_STREAM_STROKE_PX down to this. */
|
||||
const ABS_MIN_STREAM_STROKE_PX = 10;
|
||||
const ABS_MIN_STREAM_STROKE_PX = 25;
|
||||
const LANE_GAP_PX = 10;
|
||||
const CANVAS_TOP_PAD = 38;
|
||||
const CANVAS_BOTTOM_PAD = 24;
|
||||
@@ -374,38 +374,167 @@ function assignDepths(movements: ArtMovement[], lineageParents: Map<number, numb
|
||||
/** Same-lane movements must leave at least this many years between one end and the next start. */
|
||||
const LANE_MIN_GAP_YEARS = 10;
|
||||
|
||||
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes. */
|
||||
/** Pack movements into lanes — overlapping or closer than LANE_MIN_GAP_YEARS need separate lanes.
|
||||
* Movements whose label is wider than the band never share a lane (exclusive row).
|
||||
* Among valid lanes, pick the one closest to lineage parents to keep branch connectors short. */
|
||||
function assignTemporalLanes(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number
|
||||
viewEnd: number,
|
||||
labelFitsById: Map<number, boolean>,
|
||||
lineageParents: Map<number, number[]>
|
||||
): Map<number, number> {
|
||||
const depths = assignDepths(group, lineageParents);
|
||||
const spans = group
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
start: Math.max(m.start_year, viewStart),
|
||||
end: Math.min(m.end_year, viewEnd),
|
||||
depth: depths.get(m.id) ?? 0,
|
||||
}))
|
||||
.filter((s) => s.end > s.start)
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
.sort((a, b) => a.depth - b.depth || a.start - b.start || a.end - b.end);
|
||||
|
||||
const laneEnds: number[] = [];
|
||||
/** Lanes that hold (or held) a movement whose label overflows its band — no sharing. */
|
||||
const laneLabelExclusive: boolean[] = [];
|
||||
const laneById = new Map<number, number>();
|
||||
|
||||
for (const span of spans) {
|
||||
let lane = laneEnds.findIndex((endYear) => endYear + LANE_MIN_GAP_YEARS <= span.start);
|
||||
const labelFits = labelFitsById.get(span.id) ?? true;
|
||||
const parentLanes = (lineageParents.get(span.id) || [])
|
||||
.map((pid) => laneById.get(pid))
|
||||
.filter((lane): lane is number => lane != null);
|
||||
const partnerDistance = (lane: number) =>
|
||||
parentLanes.length === 0
|
||||
? 0
|
||||
: Math.min(...parentLanes.map((pl) => Math.abs(lane - pl)));
|
||||
|
||||
let lane = -1;
|
||||
if (labelFits) {
|
||||
const candidates: number[] = [];
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
if (laneEnds[i] + LANE_MIN_GAP_YEARS <= span.start && !laneLabelExclusive[i]) {
|
||||
candidates.push(i);
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => partnerDistance(a) - partnerDistance(b) || a - b);
|
||||
lane = candidates[0];
|
||||
}
|
||||
}
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(span.end);
|
||||
laneLabelExclusive.push(!labelFits);
|
||||
} else {
|
||||
laneEnds[lane] = span.end;
|
||||
}
|
||||
if (!labelFits) laneLabelExclusive[lane] = true;
|
||||
laneById.set(span.id, lane);
|
||||
}
|
||||
|
||||
return laneById;
|
||||
}
|
||||
|
||||
/** Lanes occupied by lineage partners (parents and children) of a movement. */
|
||||
function lineagePartnerLanes(
|
||||
movementId: number,
|
||||
laneById: Map<number, number>,
|
||||
lineageParents: Map<number, number[]>
|
||||
): number[] {
|
||||
const lanes: number[] = [];
|
||||
for (const parentId of lineageParents.get(movementId) || []) {
|
||||
const lane = laneById.get(parentId);
|
||||
if (lane != null) lanes.push(lane);
|
||||
}
|
||||
for (const [childId, parentIds] of lineageParents) {
|
||||
if (!parentIds.includes(movementId)) continue;
|
||||
const lane = laneById.get(childId);
|
||||
if (lane != null) lanes.push(lane);
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/** Max vertical lane gap between a movement and any lineage partner. */
|
||||
function maxLineageLaneGap(
|
||||
movementId: number,
|
||||
lane: number,
|
||||
laneById: Map<number, number>,
|
||||
lineageParents: Map<number, number[]>
|
||||
): number {
|
||||
const partners = lineagePartnerLanes(movementId, laneById, lineageParents);
|
||||
if (partners.length === 0) return 0;
|
||||
return Math.max(...partners.map((pl) => Math.abs(lane - pl)));
|
||||
}
|
||||
|
||||
/** Nudge movements onto lanes that minimize branch vertical span. */
|
||||
function minimizeLineageVerticalSpan(
|
||||
laneById: Map<number, number>,
|
||||
movements: ArtMovement[],
|
||||
lineageParents: Map<number, number[]>,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
labelFitsById: Map<number, boolean>
|
||||
): void {
|
||||
if (lineageParents.size === 0) return;
|
||||
|
||||
const movementsById = new Map(movements.map((m) => [m.id, m]));
|
||||
const maxLane = () => maxLaneIndex(laneById);
|
||||
|
||||
for (let iter = 0; iter < 16; iter++) {
|
||||
let changed = false;
|
||||
const order = [...movements].sort(
|
||||
(a, b) =>
|
||||
(lineagePartnerLanes(b.id, laneById, lineageParents).length > 0 ? 1 : 0) -
|
||||
(lineagePartnerLanes(a.id, laneById, lineageParents).length > 0 ? 1 : 0) ||
|
||||
a.start_year - b.start_year
|
||||
);
|
||||
|
||||
for (const movement of order) {
|
||||
const current = laneById.get(movement.id);
|
||||
if (current == null) continue;
|
||||
const partners = lineagePartnerLanes(movement.id, laneById, lineageParents);
|
||||
if (partners.length === 0) continue;
|
||||
|
||||
const currentGap = maxLineageLaneGap(movement.id, current, laneById, lineageParents);
|
||||
if (currentGap === 0) continue;
|
||||
|
||||
const candidates: number[] = [];
|
||||
for (let lane = 0; lane <= maxLane() + 1; lane++) {
|
||||
if (
|
||||
!laneHasYearConflict(
|
||||
laneById,
|
||||
movementsById,
|
||||
movement.id,
|
||||
lane,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
labelFitsById
|
||||
)
|
||||
) {
|
||||
candidates.push(lane);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const ga = maxLineageLaneGap(movement.id, a, laneById, lineageParents);
|
||||
const gb = maxLineageLaneGap(movement.id, b, laneById, lineageParents);
|
||||
if (ga !== gb) return ga - gb;
|
||||
return Math.abs(a - current) - Math.abs(b - current);
|
||||
});
|
||||
|
||||
const best = candidates[0];
|
||||
if (best != null && best !== current && maxLineageLaneGap(movement.id, best, laneById, lineageParents) < currentGap) {
|
||||
laneById.set(movement.id, best);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) break;
|
||||
}
|
||||
}
|
||||
|
||||
function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15): boolean {
|
||||
return a0 < b1 - tol && b0 < a1 - tol;
|
||||
}
|
||||
@@ -474,19 +603,39 @@ function clippedMovementSpan(
|
||||
};
|
||||
}
|
||||
|
||||
function laneHasLabelShareConflict(
|
||||
laneById: Map<number, number>,
|
||||
movementId: number,
|
||||
targetLane: number,
|
||||
labelFitsById: Map<number, boolean>
|
||||
): boolean {
|
||||
const meFits = labelFitsById.get(movementId) ?? true;
|
||||
for (const [otherId, lane] of laneById) {
|
||||
if (otherId === movementId || lane !== targetLane) continue;
|
||||
const otherFits = labelFitsById.get(otherId) ?? true;
|
||||
if (!meFits || !otherFits) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function laneHasYearConflict(
|
||||
laneById: Map<number, number>,
|
||||
movementsById: Map<number, ArtMovement>,
|
||||
movementId: number,
|
||||
targetLane: number,
|
||||
viewStart: number,
|
||||
viewEnd: number
|
||||
viewEnd: number,
|
||||
labelFitsById?: Map<number, boolean>
|
||||
): boolean {
|
||||
const me = movementsById.get(movementId);
|
||||
if (!me) return true;
|
||||
const mySpan = clippedMovementSpan(me, viewStart, viewEnd);
|
||||
if (!mySpan) return true;
|
||||
|
||||
if (labelFitsById && laneHasLabelShareConflict(laneById, movementId, targetLane, labelFitsById)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [otherId, lane] of laneById) {
|
||||
if (otherId === movementId || lane !== targetLane) continue;
|
||||
const other = movementsById.get(otherId);
|
||||
@@ -511,7 +660,8 @@ function findFreeLaneForMovement(
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
preferOutside?: { lo: number; hi: number },
|
||||
excludeLane?: number
|
||||
excludeLane?: number,
|
||||
labelFitsById?: Map<number, boolean>
|
||||
): number {
|
||||
const maxLane = maxLaneIndex(laneById);
|
||||
const candidates: number[] = [];
|
||||
@@ -519,17 +669,34 @@ function findFreeLaneForMovement(
|
||||
candidates.push(maxLane + 1);
|
||||
|
||||
const ranked = [...candidates].sort((a, b) => {
|
||||
const aInside =
|
||||
preferOutside != null && a > preferOutside.lo && a < preferOutside.hi ? 1 : 0;
|
||||
const bInside =
|
||||
preferOutside != null && b > preferOutside.lo && b < preferOutside.hi ? 1 : 0;
|
||||
if (aInside !== bInside) return aInside - bInside;
|
||||
if (preferOutside != null) {
|
||||
const aInside = a > preferOutside.lo && a < preferOutside.hi;
|
||||
const bInside = b > preferOutside.lo && b < preferOutside.hi;
|
||||
if (aInside !== bInside) return aInside ? 1 : -1;
|
||||
|
||||
const aAbove = a < preferOutside.lo;
|
||||
const bAbove = b < preferOutside.lo;
|
||||
if (aAbove !== bAbove) return aAbove ? -1 : 1;
|
||||
|
||||
if (aAbove && bAbove) return b - a;
|
||||
if (a > preferOutside.hi && b > preferOutside.hi) return a - b;
|
||||
}
|
||||
return a - b;
|
||||
});
|
||||
|
||||
for (const lane of ranked) {
|
||||
if (excludeLane != null && lane === excludeLane) continue;
|
||||
if (!laneHasYearConflict(laneById, movementsById, movementId, lane, viewStart, viewEnd)) {
|
||||
if (
|
||||
!laneHasYearConflict(
|
||||
laneById,
|
||||
movementsById,
|
||||
movementId,
|
||||
lane,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
labelFitsById
|
||||
)
|
||||
) {
|
||||
return lane;
|
||||
}
|
||||
}
|
||||
@@ -539,57 +706,77 @@ function findFreeLaneForMovement(
|
||||
/**
|
||||
* After temporal packing, pull lineage parent/child pairs onto nearby (or shared)
|
||||
* lanes, then displace unrelated movements out of remaining branch corridors.
|
||||
* Attract runs before clear, and never again afterward — mixing them caused
|
||||
* Gothic to leapfrog downward while Early/Northern stayed in the Byzantine→Gothic
|
||||
* transition (see High Renaissance stuck on lane 0 vs Early on lane 2).
|
||||
* Phase A attracts pairs; phase B clears foreigners (preferring rows above the
|
||||
* corridor) and re-attracts child toward parent after each displacement.
|
||||
*/
|
||||
function refineLanesForLineageCorridors(
|
||||
laneById: Map<number, number>,
|
||||
movements: ArtMovement[],
|
||||
lineageParents: Map<number, number[]>,
|
||||
viewStart: number,
|
||||
viewEnd: number
|
||||
viewEnd: number,
|
||||
labelFitsById: Map<number, boolean>
|
||||
): void {
|
||||
if (movements.length === 0 || lineageParents.size === 0) return;
|
||||
|
||||
const movementsById = new Map(movements.map((m) => [m.id, m]));
|
||||
const MAX_ATTRACT_ITERS = 8;
|
||||
const MAX_CLEAR_ITERS = 8;
|
||||
const MAX_CLEAR_ITERS = 20;
|
||||
|
||||
const tryMoveToLane = (movementId: number, desired: number): boolean => {
|
||||
if (desired < 0) return false;
|
||||
const current = laneById.get(movementId);
|
||||
if (current === desired) return false;
|
||||
if (laneHasYearConflict(laneById, movementsById, movementId, desired, viewStart, viewEnd)) {
|
||||
if (
|
||||
laneHasYearConflict(
|
||||
laneById,
|
||||
movementsById,
|
||||
movementId,
|
||||
desired,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
labelFitsById
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
laneById.set(movementId, desired);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Pull endpoints within one lane (including sharing a lane when years allow). */
|
||||
/** Pull endpoints as close as possible (same lane when years allow). */
|
||||
const tryAttractPair = (parentId: number, childId: number): boolean => {
|
||||
const parentLane = laneById.get(parentId);
|
||||
const childLane = laneById.get(childId);
|
||||
if (parentLane == null || childLane == null) return false;
|
||||
if (Math.abs(parentLane - childLane) <= 1) return false;
|
||||
if (parentLane === childLane) return false;
|
||||
|
||||
// Prefer landing on the partner's lane first (covers High→Early same-lane case).
|
||||
const childTargets = [parentLane, parentLane + 1, parentLane - 1];
|
||||
const dist = Math.abs(parentLane - childLane);
|
||||
const towardParent = childLane > parentLane ? 1 : -1;
|
||||
|
||||
const childTargets =
|
||||
dist > 1
|
||||
? [parentLane, parentLane + towardParent, parentLane - towardParent]
|
||||
: [parentLane];
|
||||
for (const desired of childTargets) {
|
||||
if (tryMoveToLane(childId, desired)) return true;
|
||||
}
|
||||
|
||||
const parentTargets = [childLane, childLane + 1, childLane - 1];
|
||||
const towardChild = parentLane > childLane ? 1 : -1;
|
||||
const parentTargets =
|
||||
dist > 1
|
||||
? [childLane, childLane - towardChild, childLane + towardChild]
|
||||
: [childLane];
|
||||
for (const desired of parentTargets) {
|
||||
if (tryMoveToLane(parentId, desired)) return true;
|
||||
}
|
||||
|
||||
if (dist > 1 && tryMoveToLane(childId, childLane - towardParent)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Phase A: attract linked pairs only.
|
||||
for (let iter = 0; iter < MAX_ATTRACT_ITERS; iter++) {
|
||||
// Phase A: attract linked pairs — minimize vertical branch length.
|
||||
for (let iter = 0; iter < 16; iter++) {
|
||||
let changed = false;
|
||||
for (const [childId, parentIds] of lineageParents) {
|
||||
if (!laneById.has(childId)) continue;
|
||||
@@ -666,11 +853,13 @@ function refineLanesForLineageCorridors(
|
||||
viewStart,
|
||||
viewEnd,
|
||||
{ lo, hi },
|
||||
lane
|
||||
lane,
|
||||
labelFitsById
|
||||
);
|
||||
if (nextLane !== lane) {
|
||||
laneById.set(movement.id, nextLane);
|
||||
changed = true;
|
||||
if (tryAttractPair(parentId, childId)) changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,6 +867,15 @@ function refineLanesForLineageCorridors(
|
||||
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
minimizeLineageVerticalSpan(
|
||||
laneById,
|
||||
movements,
|
||||
lineageParents,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
labelFitsById
|
||||
);
|
||||
}
|
||||
|
||||
/** Renumber lanes to dense 0..n-1 so empty gaps do not waste vertical space. */
|
||||
@@ -839,6 +1037,8 @@ interface LabelPlacement {
|
||||
layout: MovementLayout;
|
||||
leftPct: number;
|
||||
topPct: number;
|
||||
/** False when the label is wider than the visible movement band — hide until hover or adjacent gap. */
|
||||
fitsInBand: boolean;
|
||||
}
|
||||
|
||||
interface PixelRect {
|
||||
@@ -858,8 +1058,39 @@ function estimateLabelWidthPx(name: string): number {
|
||||
return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth));
|
||||
}
|
||||
|
||||
function labelHeightPx(hasEra: boolean): number {
|
||||
return hasEra ? 34 : 20;
|
||||
/** Whether the movement name label fits inside its clipped horizontal band. */
|
||||
function movementLabelFitsInBand(
|
||||
movement: ArtMovement,
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
canvasWidth: number
|
||||
): boolean {
|
||||
if (canvasWidth <= 0) return true;
|
||||
const clip = clippedMovementSpan(movement, viewStart, viewEnd);
|
||||
if (!clip) return true;
|
||||
const bandWidthPct = Math.max(clip.xEnd - clip.xStart, 0.5);
|
||||
const bandWidthPx = (bandWidthPct / 100) * canvasWidth;
|
||||
return estimateLabelWidthPx(movement.name) <= bandWidthPx;
|
||||
}
|
||||
|
||||
function buildLabelFitsById(
|
||||
group: ArtMovement[],
|
||||
viewStart: number,
|
||||
viewEnd: number,
|
||||
canvasWidth: number
|
||||
): Map<number, boolean> {
|
||||
const map = new Map<number, boolean>();
|
||||
for (const movement of group) {
|
||||
map.set(
|
||||
movement.id,
|
||||
movementLabelFitsInBand(movement, viewStart, viewEnd, canvasWidth)
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function labelHeightPx(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
function rectsOverlap(a: PixelRect, b: PixelRect, pad = LABEL_MIN_SEPARATION_PX): boolean {
|
||||
@@ -961,6 +1192,68 @@ function clampLabelLeftPct(leftPct: number, widthPx: number, canvasWidth: number
|
||||
return Math.min(Math.max(leftPct, halfWidthPct + 0.5), 100 - halfWidthPct - 0.5);
|
||||
}
|
||||
|
||||
function streamsOverlapVertically(a: MovementLayout, b: MovementLayout): boolean {
|
||||
return Math.min(a.yEnd, b.yEnd) > Math.max(a.y, b.y);
|
||||
}
|
||||
|
||||
/** Horizontal gap (in % of canvas width) before/after a movement, ignoring non-overlapping rows. */
|
||||
function horizontalGapsBesideMovement(
|
||||
layout: MovementLayout,
|
||||
layouts: MovementLayout[]
|
||||
): { beforePct: number; afterPct: number } {
|
||||
let leftBound = 0;
|
||||
let rightBound = 100;
|
||||
for (const other of layouts) {
|
||||
if (other.movement.id === layout.movement.id) continue;
|
||||
if (!streamsOverlapVertically(layout, other)) continue;
|
||||
if (other.xEnd <= layout.xStart) leftBound = Math.max(leftBound, other.xEnd);
|
||||
if (other.xStart >= layout.xEnd) rightBound = Math.min(rightBound, other.xStart);
|
||||
}
|
||||
return { beforePct: layout.xStart - leftBound, afterPct: rightBound - layout.xEnd };
|
||||
}
|
||||
|
||||
function tryPlaceAdjacentLabel(
|
||||
layout: MovementLayout,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
side: 'before' | 'after',
|
||||
layoutHeight: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
isClear: (rect: PixelRect) => boolean
|
||||
): { placement: LabelPlacement; rect: PixelRect } | null {
|
||||
const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100;
|
||||
const halfWidthPct = ((widthPx / canvasWidth) * 100) / 2;
|
||||
const leftPct =
|
||||
side === 'before'
|
||||
? layout.xStart - padPct - halfWidthPct
|
||||
: layout.xEnd + padPct + halfWidthPct;
|
||||
|
||||
if (leftPct - halfWidthPct < 0.5 || leftPct + halfWidthPct > 99.5) return null;
|
||||
|
||||
const streamY = yOnStream(layout, leftPct);
|
||||
const rect = labelOnBandRect(
|
||||
leftPct,
|
||||
streamY,
|
||||
widthPx,
|
||||
heightPx,
|
||||
layoutHeight,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
if (!isClear(rect)) return null;
|
||||
|
||||
return {
|
||||
placement: {
|
||||
layout,
|
||||
leftPct,
|
||||
topPct: (streamY / layoutHeight) * 100,
|
||||
fitsInBand: true,
|
||||
},
|
||||
rect,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLabelPlacements(
|
||||
layouts: MovementLayout[],
|
||||
artistPlacements: ArtistPlacement[],
|
||||
@@ -971,16 +1264,19 @@ function buildLabelPlacements(
|
||||
): LabelPlacement[] {
|
||||
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
|
||||
|
||||
const portraitObstacles = artistPlacements.map((p) =>
|
||||
portraitObstacleRect(
|
||||
const portraitObstacles = artistPlacements.map((p) => ({
|
||||
movementId: p.layout.movement.id,
|
||||
rect: portraitObstacleRect(
|
||||
p.portraitX,
|
||||
p.y,
|
||||
p.layout.portraitSizePx,
|
||||
layoutHeight,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
)
|
||||
);
|
||||
),
|
||||
}));
|
||||
|
||||
const allPortraitRects = portraitObstacles.map((p) => p.rect);
|
||||
|
||||
const bandObstacles = layouts.map((layout) => ({
|
||||
id: layout.movement.id,
|
||||
@@ -1005,15 +1301,225 @@ function buildLabelPlacements(
|
||||
const placed: LabelPlacement[] = [];
|
||||
const placedLabelRects: PixelRect[] = [];
|
||||
|
||||
const makeIsClear =
|
||||
(movementId: number) =>
|
||||
(rect: PixelRect): boolean => {
|
||||
const otherPortraits = portraitObstacles.filter((p) => p.movementId !== movementId).map((p) => p.rect);
|
||||
const otherBands = bandObstacles.filter((b) => b.id !== movementId).map((b) => b.rect);
|
||||
const otherBranches = branchObstacles
|
||||
.filter((b) => b.fromId !== movementId && b.toId !== movementId)
|
||||
.map((b) => b.rect);
|
||||
return (
|
||||
!otherPortraits.some((o) => rectsOverlap(rect, o)) &&
|
||||
!placedLabelRects.some((o) => rectsOverlap(rect, o)) &&
|
||||
!otherBands.some((o) => rectsOverlap(rect, o)) &&
|
||||
!otherBranches.some((o) => rectsOverlap(rect, o))
|
||||
);
|
||||
};
|
||||
|
||||
const makeIsClearRelaxed =
|
||||
(movementId: number) =>
|
||||
(rect: PixelRect): boolean => {
|
||||
const otherPortraits = portraitObstacles.filter((p) => p.movementId !== movementId).map((p) => p.rect);
|
||||
const otherBranches = branchObstacles
|
||||
.filter((b) => b.fromId !== movementId && b.toId !== movementId)
|
||||
.map((b) => b.rect);
|
||||
return (
|
||||
!otherPortraits.some((o) => rectsOverlap(rect, o)) &&
|
||||
!placedLabelRects.some((o) => rectsOverlap(rect, o, 0)) &&
|
||||
!otherBranches.some((o) => rectsOverlap(rect, o))
|
||||
);
|
||||
};
|
||||
|
||||
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 heightPx = labelHeightPx();
|
||||
const span = Math.max(layout.xEnd - layout.xStart, 0.5);
|
||||
const fitsInBand = layout.labelFitsInBand;
|
||||
const preferredX = layout.xStart + span * 0.5;
|
||||
const isClear = makeIsClear(layout.movement.id);
|
||||
const isClearRelaxed = makeIsClearRelaxed(layout.movement.id);
|
||||
|
||||
if (!fitsInBand) {
|
||||
const widthPct = (widthPx / canvasWidth) * 100;
|
||||
const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100;
|
||||
const halfWidthPct = widthPct / 2;
|
||||
|
||||
// Priority 1: center of movement (label fits and no portrait overlap)
|
||||
const centerX = layout.xStart + span * 0.5;
|
||||
const centerStreamY = yOnStream(layout, centerX);
|
||||
const centerRect = labelOnBandRect(centerX, centerStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
if (widthPx <= (span / 100) * canvasWidth && isClear(centerRect)) {
|
||||
placed.push({ layout, leftPct: centerX, topPct: (centerStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(centerRect);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 2: right end of movement, fully inside band borders
|
||||
const endInsideX = layout.xEnd - halfWidthPct - padPct;
|
||||
if (endInsideX - halfWidthPct >= layout.xStart) {
|
||||
const clampedEnd = clampLabelLeftPct(endInsideX, widthPx, canvasWidth);
|
||||
const endStreamY = yOnStream(layout, clampedEnd);
|
||||
const endRect = labelOnBandRect(clampedEnd, endStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
if (isClear(endRect)) {
|
||||
placed.push({ layout, leftPct: clampedEnd, topPct: (endStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(endRect);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: overflow right or left (up to 100%), prefer side with fewer transition line overlaps
|
||||
const maxOverflowPct = widthPct;
|
||||
const overflowRightX = layout.xEnd + maxOverflowPct - halfWidthPct;
|
||||
const clampedOverflowR = clampLabelLeftPct(overflowRightX, widthPx, canvasWidth);
|
||||
const overflowRStreamY = yOnStream(layout, Math.min(clampedOverflowR, layout.xEnd));
|
||||
const overflowRRect = labelOnBandRect(clampedOverflowR, overflowRStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
const rightClear = isClearRelaxed(overflowRRect);
|
||||
|
||||
const overflowLeftX = layout.xStart - maxOverflowPct + halfWidthPct;
|
||||
const clampedOverflowL = clampLabelLeftPct(overflowLeftX, widthPx, canvasWidth);
|
||||
const overflowLStreamY = yOnStream(layout, Math.max(clampedOverflowL, layout.xStart));
|
||||
const overflowLRect = labelOnBandRect(clampedOverflowL, overflowLStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
const leftClear = isClearRelaxed(overflowLRect);
|
||||
|
||||
if (rightClear && leftClear) {
|
||||
const allBranchRects = branchObstacles.map((b) => b.rect);
|
||||
const rightBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowRRect, o, 0), 0);
|
||||
const leftBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowLRect, o, 0), 0);
|
||||
if (leftBranchOverlap <= rightBranchOverlap) {
|
||||
placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowLRect);
|
||||
} else {
|
||||
placed.push({ layout, leftPct: clampedOverflowR, topPct: (overflowRStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowRRect);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rightClear) {
|
||||
placed.push({ layout, leftPct: clampedOverflowR, topPct: (overflowRStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowRRect);
|
||||
continue;
|
||||
}
|
||||
if (leftClear) {
|
||||
placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowLRect);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 4: adjacent free space before/after
|
||||
const minGapPct = widthPct + padPct * 2;
|
||||
const { beforePct, afterPct } = horizontalGapsBesideMovement(layout, layouts);
|
||||
|
||||
const sides: ('before' | 'after')[] = [];
|
||||
if (beforePct >= minGapPct) sides.push('before');
|
||||
if (afterPct >= minGapPct) sides.push('after');
|
||||
sides.sort((a, b) => {
|
||||
const gapA = a === 'before' ? beforePct : afterPct;
|
||||
const gapB = b === 'before' ? beforePct : afterPct;
|
||||
return gapB - gapA;
|
||||
});
|
||||
|
||||
let adjacent: { placement: LabelPlacement; rect: PixelRect } | null = null;
|
||||
for (const side of sides) {
|
||||
adjacent = tryPlaceAdjacentLabel(
|
||||
layout,
|
||||
widthPx,
|
||||
heightPx,
|
||||
side,
|
||||
layoutHeight,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
isClearRelaxed
|
||||
);
|
||||
if (adjacent) break;
|
||||
}
|
||||
|
||||
if (adjacent) {
|
||||
placed.push(adjacent.placement);
|
||||
placedLabelRects.push(adjacent.rect);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: hidden until hover
|
||||
const bandWidthPx = (span / 100) * canvasWidth;
|
||||
const leftPct = clampLabelLeftPct(preferredX, Math.min(widthPx, bandWidthPx || widthPx), canvasWidth);
|
||||
const streamY = yOnStream(layout, leftPct);
|
||||
placed.push({
|
||||
layout,
|
||||
leftPct,
|
||||
topPct: (streamY / layoutHeight) * 100,
|
||||
fitsInBand: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 1: center of band if no portrait overlap
|
||||
const centerX = preferredX;
|
||||
const centerStreamY = yOnStream(layout, centerX);
|
||||
const centerRect = labelOnBandRect(centerX, centerStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
if (isClear(centerRect)) {
|
||||
placed.push({ layout, leftPct: centerX, topPct: (centerStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(centerRect);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 2: right end inside band
|
||||
const widthPct = (widthPx / canvasWidth) * 100;
|
||||
const halfWidthPct = widthPct / 2;
|
||||
const padPct = (LABEL_BAND_PAD_PX / canvasWidth) * 100;
|
||||
const endInsideX = layout.xEnd - halfWidthPct - padPct;
|
||||
if (endInsideX - halfWidthPct >= layout.xStart) {
|
||||
const clampedEnd = clampLabelLeftPct(endInsideX, widthPx, canvasWidth);
|
||||
const endStreamY = yOnStream(layout, clampedEnd);
|
||||
const endRect = labelOnBandRect(clampedEnd, endStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
if (isClear(endRect)) {
|
||||
placed.push({ layout, leftPct: clampedEnd, topPct: (endStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(endRect);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: overflow right or left (up to 100%), prefer side with fewer transition line overlaps
|
||||
const maxOverflowPct = widthPct;
|
||||
const overflowX = layout.xEnd + maxOverflowPct - halfWidthPct;
|
||||
const clampedOverflow = clampLabelLeftPct(overflowX, widthPx, canvasWidth);
|
||||
const overflowStreamY = yOnStream(layout, Math.min(clampedOverflow, layout.xEnd));
|
||||
const overflowRect = labelOnBandRect(clampedOverflow, overflowStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
const rightClear = isClearRelaxed(overflowRect);
|
||||
|
||||
const overflowLeftX = layout.xStart - maxOverflowPct + halfWidthPct;
|
||||
const clampedOverflowL = clampLabelLeftPct(overflowLeftX, widthPx, canvasWidth);
|
||||
const overflowLStreamY = yOnStream(layout, Math.max(clampedOverflowL, layout.xStart));
|
||||
const overflowLRect = labelOnBandRect(clampedOverflowL, overflowLStreamY, widthPx, heightPx, layoutHeight, canvasWidth, canvasHeight);
|
||||
const leftClear = isClearRelaxed(overflowLRect);
|
||||
|
||||
if (rightClear && leftClear) {
|
||||
const allBranchRects = branchObstacles.map((b) => b.rect);
|
||||
const rightBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowRect, o, 0), 0);
|
||||
const leftBranchOverlap = allBranchRects.reduce((sum, o) => sum + overlapArea(overflowLRect, o, 0), 0);
|
||||
if (leftBranchOverlap <= rightBranchOverlap) {
|
||||
placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowLRect);
|
||||
} else {
|
||||
placed.push({ layout, leftPct: clampedOverflow, topPct: (overflowStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowRect);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rightClear) {
|
||||
placed.push({ layout, leftPct: clampedOverflow, topPct: (overflowStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowRect);
|
||||
continue;
|
||||
}
|
||||
if (leftClear) {
|
||||
placed.push({ layout, leftPct: clampedOverflowL, topPct: (overflowLStreamY / layoutHeight) * 100, fitsInBand: true });
|
||||
placedLabelRects.push(overflowLRect);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 4: scan candidates (existing fallback)
|
||||
const xCandidates = new Set<number>();
|
||||
const steps = Math.max(12, Math.ceil(span * 2));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
@@ -1045,19 +1551,13 @@ function buildLabelPlacements(
|
||||
|
||||
const scoreRect = (rect: PixelRect) => {
|
||||
let score = 0;
|
||||
for (const o of portraitObstacles) score += overlapArea(rect, o) * 4;
|
||||
for (const o of allPortraitRects) score += overlapArea(rect, o) * 4;
|
||||
for (const o of placedLabelRects) score += overlapArea(rect, o) * 5;
|
||||
for (const o of otherBands) score += overlapArea(rect, o) * 3;
|
||||
for (const o of otherBranches) score += overlapArea(rect, o) * 4;
|
||||
return score;
|
||||
};
|
||||
|
||||
const isClear = (rect: PixelRect) =>
|
||||
!portraitObstacles.some((o) => rectsOverlap(rect, o)) &&
|
||||
!placedLabelRects.some((o) => rectsOverlap(rect, o)) &&
|
||||
!otherBands.some((o) => rectsOverlap(rect, o)) &&
|
||||
!otherBranches.some((o) => rectsOverlap(rect, o));
|
||||
|
||||
let chosen: LabelPlacement | null = null;
|
||||
let chosenRect: PixelRect | null = null;
|
||||
let bestFallback: { placement: LabelPlacement; rect: PixelRect; score: number } | null = null;
|
||||
@@ -1078,6 +1578,7 @@ function buildLabelPlacements(
|
||||
layout,
|
||||
leftPct,
|
||||
topPct: (streamY / layoutHeight) * 100,
|
||||
fitsInBand: true,
|
||||
};
|
||||
|
||||
if (isClear(rect)) {
|
||||
@@ -1484,16 +1985,31 @@ export default function MovementBands({
|
||||
);
|
||||
}
|
||||
|
||||
// Label fit is decided before lane packing — overflow names get an exclusive row.
|
||||
const labelFitsById = buildLabelFitsById(
|
||||
visibleMovements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
canvasWidth
|
||||
);
|
||||
|
||||
// Pack all visible movements into shared horizontal lanes whenever their
|
||||
// clipped time spans do not overlap, then clear unrelated streams out of
|
||||
// lineage branch corridors (parent/child adjacency preferred).
|
||||
const laneIndex = assignTemporalLanes(visibleMovements, viewStart, viewEnd);
|
||||
const laneIndex = assignTemporalLanes(
|
||||
visibleMovements,
|
||||
viewStart,
|
||||
viewEnd,
|
||||
labelFitsById,
|
||||
lineageParents
|
||||
);
|
||||
refineLanesForLineageCorridors(
|
||||
laneIndex,
|
||||
visibleMovements,
|
||||
lineageParents,
|
||||
viewStart,
|
||||
viewEnd
|
||||
viewEnd,
|
||||
labelFitsById
|
||||
);
|
||||
compactLanes(laneIndex);
|
||||
|
||||
@@ -1527,16 +2043,17 @@ export default function MovementBands({
|
||||
|
||||
let maxStrokePx = ABS_MIN_STREAM_STROKE_PX;
|
||||
const strokeById = new Map<number, number>();
|
||||
const scaledMinStroke = Math.max(10, ABS_MIN_STREAM_STROKE_PX * Math.min(1, fitScale));
|
||||
for (const movement of visibleMovements) {
|
||||
const lane = laneIndex.get(movement.id) ?? 0;
|
||||
const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale;
|
||||
const laneCap = Math.max(
|
||||
ABS_MIN_STREAM_STROKE_PX,
|
||||
scaledMinStroke,
|
||||
laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale)
|
||||
);
|
||||
const strokePx = Math.min(
|
||||
MAX_STREAM_STROKE_PX,
|
||||
Math.max(ABS_MIN_STREAM_STROKE_PX, Math.min(raw, laneCap))
|
||||
Math.max(scaledMinStroke, Math.min(raw, laneCap))
|
||||
);
|
||||
strokeById.set(movement.id, strokePx);
|
||||
maxStrokePx = Math.max(maxStrokePx, strokePx);
|
||||
@@ -1582,6 +2099,7 @@ export default function MovementBands({
|
||||
strokePx,
|
||||
portraitSizePx: portraitSizeForStroke(strokePx),
|
||||
displayColor: vividMovementColor(movement.color),
|
||||
labelFitsInBand: labelFitsById.get(movement.id) ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1635,7 +2153,7 @@ export default function MovementBands({
|
||||
portraitSizePx,
|
||||
streamCurveOffset,
|
||||
};
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight, canvasWidth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset };
|
||||
@@ -1743,7 +2261,7 @@ export default function MovementBands({
|
||||
return (
|
||||
<div className="movements-flow">
|
||||
<p className="movements-flow-caption">
|
||||
Scroll to zoom · drag to pan · streams blend from movement to movement through history
|
||||
Scroll to zoom · drag to pan · each movement stream is a solid colour band through history
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -1813,54 +2331,6 @@ export default function MovementBands({
|
||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
|
||||
</linearGradient>
|
||||
))}
|
||||
|
||||
{!fastGraphics &&
|
||||
drawLayouts.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?.displayColor ?? layout.displayColor;
|
||||
|
||||
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.48} />
|
||||
<stop offset="16%" stopColor={layout.displayColor} stopOpacity={0.58} />
|
||||
<stop offset="32%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={layout.displayColor}
|
||||
stopOpacity={layout.movement.start_definite ? 0.5 : 0.18}
|
||||
/>
|
||||
)}
|
||||
<stop offset="50%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<stop offset="68%" stopColor={layout.displayColor} stopOpacity={0.64} />
|
||||
<stop offset="84%" stopColor={layout.displayColor} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={layout.displayColor} stopOpacity={0} />
|
||||
</>
|
||||
) : (
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={layout.displayColor}
|
||||
stopOpacity={layout.movement.end_definite ? 0.5 : 0.18}
|
||||
/>
|
||||
)}
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{fastGraphics ? (
|
||||
@@ -1920,9 +2390,9 @@ export default function MovementBands({
|
||||
key={`stream-${layout.movement.id}`}
|
||||
d={d}
|
||||
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
||||
stroke={layout.displayColor}
|
||||
fill="none"
|
||||
style={{ strokeWidth: layout.strokePx }}
|
||||
style={{ strokeWidth: layout.strokePx, strokeOpacity: 0.64 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1992,27 +2462,28 @@ export default function MovementBands({
|
||||
</div>
|
||||
|
||||
<div className="movements-flow-labels">
|
||||
{labelPlacements.map(({ layout, leftPct, topPct }) => (
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
type="button"
|
||||
className="movement-flow-label movement-flow-label-btn"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
}}
|
||||
title={`Open ${layout.movement.name} gallery hall`}
|
||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
||||
onMouseLeave={clearMovementHoverSoon}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="movement-name">{layout.movement.name}</span>
|
||||
{layout.movement.era_name && (
|
||||
<span className="movement-era">{layout.movement.era_name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{labelPlacements.map(({ layout, leftPct, topPct, fitsInBand }) => {
|
||||
const showLabel = fitsInBand || hoveredMovementId === layout.movement.id;
|
||||
if (!showLabel) return null;
|
||||
return (
|
||||
<button
|
||||
key={`label-${layout.movement.id}`}
|
||||
type="button"
|
||||
className={`movement-flow-label movement-flow-label-btn${fitsInBand ? '' : ' movement-flow-label-hover-reveal'}`}
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
}}
|
||||
title={`Open ${layout.movement.name} gallery hall`}
|
||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
||||
onMouseLeave={clearMovementHoverSoon}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="movement-name">{layout.movement.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="movements-flow-artists">
|
||||
|
||||
Reference in New Issue
Block a user