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
|
### 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 |
|
| Feature | Implementation |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### 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 portrait | Open artist biography |
|
||||||
| Click **movement name** (label on stream) | Open **movement gallery** for that movement |
|
| 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.
|
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;
|
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 {
|
.movement-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ interface MovementLayout {
|
|||||||
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
/** Band thickness in CSS pixels (proportional to influence_link_count). */
|
||||||
strokePx: number;
|
strokePx: number;
|
||||||
portraitSizePx: number;
|
portraitSizePx: number;
|
||||||
/** Saturated display color for the dark flow canvas. */
|
/** True when the on-band name label is wider than the visible movement span. */
|
||||||
displayColor: string;
|
labelFitsInBand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BranchSegment {
|
interface BranchSegment {
|
||||||
@@ -51,7 +51,7 @@ interface BranchSegment {
|
|||||||
const MAX_STREAM_STROKE_PX = 54;
|
const MAX_STREAM_STROKE_PX = 54;
|
||||||
const MIN_STREAM_STROKE_PX = 28;
|
const MIN_STREAM_STROKE_PX = 28;
|
||||||
/** When many lanes must fit, stroke may shrink below MIN_STREAM_STROKE_PX down to this. */
|
/** 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 LANE_GAP_PX = 10;
|
||||||
const CANVAS_TOP_PAD = 38;
|
const CANVAS_TOP_PAD = 38;
|
||||||
const CANVAS_BOTTOM_PAD = 24;
|
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. */
|
/** Same-lane movements must leave at least this many years between one end and the next start. */
|
||||||
const LANE_MIN_GAP_YEARS = 10;
|
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(
|
function assignTemporalLanes(
|
||||||
group: ArtMovement[],
|
group: ArtMovement[],
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
viewEnd: number
|
viewEnd: number,
|
||||||
|
labelFitsById: Map<number, boolean>,
|
||||||
|
lineageParents: Map<number, number[]>
|
||||||
): Map<number, number> {
|
): Map<number, number> {
|
||||||
|
const depths = assignDepths(group, lineageParents);
|
||||||
const spans = group
|
const spans = group
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
start: Math.max(m.start_year, viewStart),
|
start: Math.max(m.start_year, viewStart),
|
||||||
end: Math.min(m.end_year, viewEnd),
|
end: Math.min(m.end_year, viewEnd),
|
||||||
|
depth: depths.get(m.id) ?? 0,
|
||||||
}))
|
}))
|
||||||
.filter((s) => s.end > s.start)
|
.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[] = [];
|
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>();
|
const laneById = new Map<number, number>();
|
||||||
|
|
||||||
for (const span of spans) {
|
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) {
|
if (lane === -1) {
|
||||||
lane = laneEnds.length;
|
lane = laneEnds.length;
|
||||||
laneEnds.push(span.end);
|
laneEnds.push(span.end);
|
||||||
|
laneLabelExclusive.push(!labelFits);
|
||||||
} else {
|
} else {
|
||||||
laneEnds[lane] = span.end;
|
laneEnds[lane] = span.end;
|
||||||
}
|
}
|
||||||
|
if (!labelFits) laneLabelExclusive[lane] = true;
|
||||||
laneById.set(span.id, lane);
|
laneById.set(span.id, lane);
|
||||||
}
|
}
|
||||||
|
|
||||||
return laneById;
|
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 {
|
function spansOverlap(a0: number, a1: number, b0: number, b1: number, tol = 0.15): boolean {
|
||||||
return a0 < b1 - tol && b0 < a1 - tol;
|
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(
|
function laneHasYearConflict(
|
||||||
laneById: Map<number, number>,
|
laneById: Map<number, number>,
|
||||||
movementsById: Map<number, ArtMovement>,
|
movementsById: Map<number, ArtMovement>,
|
||||||
movementId: number,
|
movementId: number,
|
||||||
targetLane: number,
|
targetLane: number,
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
viewEnd: number
|
viewEnd: number,
|
||||||
|
labelFitsById?: Map<number, boolean>
|
||||||
): boolean {
|
): boolean {
|
||||||
const me = movementsById.get(movementId);
|
const me = movementsById.get(movementId);
|
||||||
if (!me) return true;
|
if (!me) return true;
|
||||||
const mySpan = clippedMovementSpan(me, viewStart, viewEnd);
|
const mySpan = clippedMovementSpan(me, viewStart, viewEnd);
|
||||||
if (!mySpan) return true;
|
if (!mySpan) return true;
|
||||||
|
|
||||||
|
if (labelFitsById && laneHasLabelShareConflict(laneById, movementId, targetLane, labelFitsById)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [otherId, lane] of laneById) {
|
for (const [otherId, lane] of laneById) {
|
||||||
if (otherId === movementId || lane !== targetLane) continue;
|
if (otherId === movementId || lane !== targetLane) continue;
|
||||||
const other = movementsById.get(otherId);
|
const other = movementsById.get(otherId);
|
||||||
@@ -511,7 +660,8 @@ function findFreeLaneForMovement(
|
|||||||
viewStart: number,
|
viewStart: number,
|
||||||
viewEnd: number,
|
viewEnd: number,
|
||||||
preferOutside?: { lo: number; hi: number },
|
preferOutside?: { lo: number; hi: number },
|
||||||
excludeLane?: number
|
excludeLane?: number,
|
||||||
|
labelFitsById?: Map<number, boolean>
|
||||||
): number {
|
): number {
|
||||||
const maxLane = maxLaneIndex(laneById);
|
const maxLane = maxLaneIndex(laneById);
|
||||||
const candidates: number[] = [];
|
const candidates: number[] = [];
|
||||||
@@ -519,17 +669,34 @@ function findFreeLaneForMovement(
|
|||||||
candidates.push(maxLane + 1);
|
candidates.push(maxLane + 1);
|
||||||
|
|
||||||
const ranked = [...candidates].sort((a, b) => {
|
const ranked = [...candidates].sort((a, b) => {
|
||||||
const aInside =
|
if (preferOutside != null) {
|
||||||
preferOutside != null && a > preferOutside.lo && a < preferOutside.hi ? 1 : 0;
|
const aInside = a > preferOutside.lo && a < preferOutside.hi;
|
||||||
const bInside =
|
const bInside = b > preferOutside.lo && b < preferOutside.hi;
|
||||||
preferOutside != null && b > preferOutside.lo && b < preferOutside.hi ? 1 : 0;
|
if (aInside !== bInside) return aInside ? 1 : -1;
|
||||||
if (aInside !== bInside) return aInside - bInside;
|
|
||||||
|
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;
|
return a - b;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const lane of ranked) {
|
for (const lane of ranked) {
|
||||||
if (excludeLane != null && lane === excludeLane) continue;
|
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;
|
return lane;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,57 +706,77 @@ function findFreeLaneForMovement(
|
|||||||
/**
|
/**
|
||||||
* After temporal packing, pull lineage parent/child pairs onto nearby (or shared)
|
* After temporal packing, pull lineage parent/child pairs onto nearby (or shared)
|
||||||
* lanes, then displace unrelated movements out of remaining branch corridors.
|
* lanes, then displace unrelated movements out of remaining branch corridors.
|
||||||
* Attract runs before clear, and never again afterward — mixing them caused
|
* Phase A attracts pairs; phase B clears foreigners (preferring rows above the
|
||||||
* Gothic to leapfrog downward while Early/Northern stayed in the Byzantine→Gothic
|
* corridor) and re-attracts child toward parent after each displacement.
|
||||||
* transition (see High Renaissance stuck on lane 0 vs Early on lane 2).
|
|
||||||
*/
|
*/
|
||||||
function refineLanesForLineageCorridors(
|
function refineLanesForLineageCorridors(
|
||||||
laneById: Map<number, number>,
|
laneById: Map<number, number>,
|
||||||
movements: ArtMovement[],
|
movements: ArtMovement[],
|
||||||
lineageParents: Map<number, number[]>,
|
lineageParents: Map<number, number[]>,
|
||||||
viewStart: number,
|
viewStart: number,
|
||||||
viewEnd: number
|
viewEnd: number,
|
||||||
|
labelFitsById: Map<number, boolean>
|
||||||
): void {
|
): void {
|
||||||
if (movements.length === 0 || lineageParents.size === 0) return;
|
if (movements.length === 0 || lineageParents.size === 0) return;
|
||||||
|
|
||||||
const movementsById = new Map(movements.map((m) => [m.id, m]));
|
const movementsById = new Map(movements.map((m) => [m.id, m]));
|
||||||
const MAX_ATTRACT_ITERS = 8;
|
const MAX_CLEAR_ITERS = 20;
|
||||||
const MAX_CLEAR_ITERS = 8;
|
|
||||||
|
|
||||||
const tryMoveToLane = (movementId: number, desired: number): boolean => {
|
const tryMoveToLane = (movementId: number, desired: number): boolean => {
|
||||||
if (desired < 0) return false;
|
if (desired < 0) return false;
|
||||||
const current = laneById.get(movementId);
|
const current = laneById.get(movementId);
|
||||||
if (current === desired) return false;
|
if (current === desired) return false;
|
||||||
if (laneHasYearConflict(laneById, movementsById, movementId, desired, viewStart, viewEnd)) {
|
if (
|
||||||
|
laneHasYearConflict(
|
||||||
|
laneById,
|
||||||
|
movementsById,
|
||||||
|
movementId,
|
||||||
|
desired,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
labelFitsById
|
||||||
|
)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
laneById.set(movementId, desired);
|
laneById.set(movementId, desired);
|
||||||
return true;
|
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 tryAttractPair = (parentId: number, childId: number): boolean => {
|
||||||
const parentLane = laneById.get(parentId);
|
const parentLane = laneById.get(parentId);
|
||||||
const childLane = laneById.get(childId);
|
const childLane = laneById.get(childId);
|
||||||
if (parentLane == null || childLane == null) return false;
|
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 dist = Math.abs(parentLane - childLane);
|
||||||
const childTargets = [parentLane, parentLane + 1, parentLane - 1];
|
const towardParent = childLane > parentLane ? 1 : -1;
|
||||||
|
|
||||||
|
const childTargets =
|
||||||
|
dist > 1
|
||||||
|
? [parentLane, parentLane + towardParent, parentLane - towardParent]
|
||||||
|
: [parentLane];
|
||||||
for (const desired of childTargets) {
|
for (const desired of childTargets) {
|
||||||
if (tryMoveToLane(childId, desired)) return true;
|
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) {
|
for (const desired of parentTargets) {
|
||||||
if (tryMoveToLane(parentId, desired)) return true;
|
if (tryMoveToLane(parentId, desired)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dist > 1 && tryMoveToLane(childId, childLane - towardParent)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase A: attract linked pairs only.
|
// Phase A: attract linked pairs — minimize vertical branch length.
|
||||||
for (let iter = 0; iter < MAX_ATTRACT_ITERS; iter++) {
|
for (let iter = 0; iter < 16; iter++) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [childId, parentIds] of lineageParents) {
|
for (const [childId, parentIds] of lineageParents) {
|
||||||
if (!laneById.has(childId)) continue;
|
if (!laneById.has(childId)) continue;
|
||||||
@@ -666,11 +853,13 @@ function refineLanesForLineageCorridors(
|
|||||||
viewStart,
|
viewStart,
|
||||||
viewEnd,
|
viewEnd,
|
||||||
{ lo, hi },
|
{ lo, hi },
|
||||||
lane
|
lane,
|
||||||
|
labelFitsById
|
||||||
);
|
);
|
||||||
if (nextLane !== lane) {
|
if (nextLane !== lane) {
|
||||||
laneById.set(movement.id, nextLane);
|
laneById.set(movement.id, nextLane);
|
||||||
changed = true;
|
changed = true;
|
||||||
|
if (tryAttractPair(parentId, childId)) changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,6 +867,15 @@ function refineLanesForLineageCorridors(
|
|||||||
|
|
||||||
if (!changed) break;
|
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. */
|
/** Renumber lanes to dense 0..n-1 so empty gaps do not waste vertical space. */
|
||||||
@@ -839,6 +1037,8 @@ interface LabelPlacement {
|
|||||||
layout: MovementLayout;
|
layout: MovementLayout;
|
||||||
leftPct: number;
|
leftPct: number;
|
||||||
topPct: number;
|
topPct: number;
|
||||||
|
/** False when the label is wider than the visible movement band — hide until hover or adjacent gap. */
|
||||||
|
fitsInBand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PixelRect {
|
interface PixelRect {
|
||||||
@@ -858,8 +1058,39 @@ function estimateLabelWidthPx(name: string): number {
|
|||||||
return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth));
|
return Math.min(LABEL_MAX_WIDTH_PX, Math.max(52, textWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelHeightPx(hasEra: boolean): number {
|
/** Whether the movement name label fits inside its clipped horizontal band. */
|
||||||
return hasEra ? 34 : 20;
|
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 {
|
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);
|
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(
|
function buildLabelPlacements(
|
||||||
layouts: MovementLayout[],
|
layouts: MovementLayout[],
|
||||||
artistPlacements: ArtistPlacement[],
|
artistPlacements: ArtistPlacement[],
|
||||||
@@ -971,16 +1264,19 @@ function buildLabelPlacements(
|
|||||||
): LabelPlacement[] {
|
): LabelPlacement[] {
|
||||||
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
|
if (layouts.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return [];
|
||||||
|
|
||||||
const portraitObstacles = artistPlacements.map((p) =>
|
const portraitObstacles = artistPlacements.map((p) => ({
|
||||||
portraitObstacleRect(
|
movementId: p.layout.movement.id,
|
||||||
|
rect: portraitObstacleRect(
|
||||||
p.portraitX,
|
p.portraitX,
|
||||||
p.y,
|
p.y,
|
||||||
p.layout.portraitSizePx,
|
p.layout.portraitSizePx,
|
||||||
layoutHeight,
|
layoutHeight,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight
|
canvasHeight
|
||||||
)
|
),
|
||||||
);
|
}));
|
||||||
|
|
||||||
|
const allPortraitRects = portraitObstacles.map((p) => p.rect);
|
||||||
|
|
||||||
const bandObstacles = layouts.map((layout) => ({
|
const bandObstacles = layouts.map((layout) => ({
|
||||||
id: layout.movement.id,
|
id: layout.movement.id,
|
||||||
@@ -1005,15 +1301,225 @@ function buildLabelPlacements(
|
|||||||
const placed: LabelPlacement[] = [];
|
const placed: LabelPlacement[] = [];
|
||||||
const placedLabelRects: PixelRect[] = [];
|
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);
|
const sortedLayouts = [...layouts].sort((a, b) => a.y - b.y || a.xStart - b.xStart);
|
||||||
|
|
||||||
for (const layout of sortedLayouts) {
|
for (const layout of sortedLayouts) {
|
||||||
const hasEra = !!layout.movement.era_name;
|
|
||||||
const widthPx = estimateLabelWidthPx(layout.movement.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 span = Math.max(layout.xEnd - layout.xStart, 0.5);
|
||||||
|
const fitsInBand = layout.labelFitsInBand;
|
||||||
const preferredX = layout.xStart + span * 0.5;
|
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 xCandidates = new Set<number>();
|
||||||
const steps = Math.max(12, Math.ceil(span * 2));
|
const steps = Math.max(12, Math.ceil(span * 2));
|
||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
@@ -1045,19 +1551,13 @@ function buildLabelPlacements(
|
|||||||
|
|
||||||
const scoreRect = (rect: PixelRect) => {
|
const scoreRect = (rect: PixelRect) => {
|
||||||
let score = 0;
|
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 placedLabelRects) score += overlapArea(rect, o) * 5;
|
||||||
for (const o of otherBands) score += overlapArea(rect, o) * 3;
|
for (const o of otherBands) score += overlapArea(rect, o) * 3;
|
||||||
for (const o of otherBranches) score += overlapArea(rect, o) * 4;
|
for (const o of otherBranches) score += overlapArea(rect, o) * 4;
|
||||||
return score;
|
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 chosen: LabelPlacement | null = null;
|
||||||
let chosenRect: PixelRect | null = null;
|
let chosenRect: PixelRect | null = null;
|
||||||
let bestFallback: { placement: LabelPlacement; rect: PixelRect; score: number } | null = null;
|
let bestFallback: { placement: LabelPlacement; rect: PixelRect; score: number } | null = null;
|
||||||
@@ -1078,6 +1578,7 @@ function buildLabelPlacements(
|
|||||||
layout,
|
layout,
|
||||||
leftPct,
|
leftPct,
|
||||||
topPct: (streamY / layoutHeight) * 100,
|
topPct: (streamY / layoutHeight) * 100,
|
||||||
|
fitsInBand: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isClear(rect)) {
|
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
|
// Pack all visible movements into shared horizontal lanes whenever their
|
||||||
// clipped time spans do not overlap, then clear unrelated streams out of
|
// clipped time spans do not overlap, then clear unrelated streams out of
|
||||||
// lineage branch corridors (parent/child adjacency preferred).
|
// lineage branch corridors (parent/child adjacency preferred).
|
||||||
const laneIndex = assignTemporalLanes(visibleMovements, viewStart, viewEnd);
|
const laneIndex = assignTemporalLanes(
|
||||||
|
visibleMovements,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
labelFitsById,
|
||||||
|
lineageParents
|
||||||
|
);
|
||||||
refineLanesForLineageCorridors(
|
refineLanesForLineageCorridors(
|
||||||
laneIndex,
|
laneIndex,
|
||||||
visibleMovements,
|
visibleMovements,
|
||||||
lineageParents,
|
lineageParents,
|
||||||
viewStart,
|
viewStart,
|
||||||
viewEnd
|
viewEnd,
|
||||||
|
labelFitsById
|
||||||
);
|
);
|
||||||
compactLanes(laneIndex);
|
compactLanes(laneIndex);
|
||||||
|
|
||||||
@@ -1527,16 +2043,17 @@ export default function MovementBands({
|
|||||||
|
|
||||||
let maxStrokePx = ABS_MIN_STREAM_STROKE_PX;
|
let maxStrokePx = ABS_MIN_STREAM_STROKE_PX;
|
||||||
const strokeById = new Map<number, number>();
|
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) {
|
for (const movement of visibleMovements) {
|
||||||
const lane = laneIndex.get(movement.id) ?? 0;
|
const lane = laneIndex.get(movement.id) ?? 0;
|
||||||
const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale;
|
const raw = (desiredStrokeById.get(movement.id) ?? ABS_MIN_STREAM_STROKE_PX) * fitScale;
|
||||||
const laneCap = Math.max(
|
const laneCap = Math.max(
|
||||||
ABS_MIN_STREAM_STROKE_PX,
|
scaledMinStroke,
|
||||||
laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale)
|
laneHeights[lane] - LANE_GAP_PX * Math.min(1, fitScale)
|
||||||
);
|
);
|
||||||
const strokePx = Math.min(
|
const strokePx = Math.min(
|
||||||
MAX_STREAM_STROKE_PX,
|
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);
|
strokeById.set(movement.id, strokePx);
|
||||||
maxStrokePx = Math.max(maxStrokePx, strokePx);
|
maxStrokePx = Math.max(maxStrokePx, strokePx);
|
||||||
@@ -1582,6 +2099,7 @@ export default function MovementBands({
|
|||||||
strokePx,
|
strokePx,
|
||||||
portraitSizePx: portraitSizeForStroke(strokePx),
|
portraitSizePx: portraitSizeForStroke(strokePx),
|
||||||
displayColor: vividMovementColor(movement.color),
|
displayColor: vividMovementColor(movement.color),
|
||||||
|
labelFitsInBand: labelFitsById.get(movement.id) ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,7 +2153,7 @@ export default function MovementBands({
|
|||||||
portraitSizePx,
|
portraitSizePx,
|
||||||
streamCurveOffset,
|
streamCurveOffset,
|
||||||
};
|
};
|
||||||
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight]);
|
}, [visibleMovements, movements, viewStart, viewEnd, canvasHeight, canvasWidth]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset };
|
const target: AnimatedFlowGeometry = { layouts, branches, streamCurveOffset };
|
||||||
@@ -1743,7 +2261,7 @@ export default function MovementBands({
|
|||||||
return (
|
return (
|
||||||
<div className="movements-flow">
|
<div className="movements-flow">
|
||||||
<p className="movements-flow-caption">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1813,54 +2331,6 @@ export default function MovementBands({
|
|||||||
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
|
<stop offset="100%" stopColor={branch.colorTo} stopOpacity={0.55} />
|
||||||
</linearGradient>
|
</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>
|
</defs>
|
||||||
|
|
||||||
{fastGraphics ? (
|
{fastGraphics ? (
|
||||||
@@ -1920,9 +2390,9 @@ export default function MovementBands({
|
|||||||
key={`stream-${layout.movement.id}`}
|
key={`stream-${layout.movement.id}`}
|
||||||
d={d}
|
d={d}
|
||||||
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
className={`movement-stream-fill${streamHighlighted ? ' movement-stream-highlighted' : ''}`}
|
||||||
stroke={`url(#stream-grad-${layout.movement.id})`}
|
stroke={layout.displayColor}
|
||||||
fill="none"
|
fill="none"
|
||||||
style={{ strokeWidth: layout.strokePx }}
|
style={{ strokeWidth: layout.strokePx, strokeOpacity: 0.64 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1992,27 +2462,28 @@ export default function MovementBands({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="movements-flow-labels">
|
<div className="movements-flow-labels">
|
||||||
{labelPlacements.map(({ layout, leftPct, topPct }) => (
|
{labelPlacements.map(({ layout, leftPct, topPct, fitsInBand }) => {
|
||||||
<button
|
const showLabel = fitsInBand || hoveredMovementId === layout.movement.id;
|
||||||
key={`label-${layout.movement.id}`}
|
if (!showLabel) return null;
|
||||||
type="button"
|
return (
|
||||||
className="movement-flow-label movement-flow-label-btn"
|
<button
|
||||||
style={{
|
key={`label-${layout.movement.id}`}
|
||||||
left: `${leftPct}%`,
|
type="button"
|
||||||
top: `${topPct}%`,
|
className={`movement-flow-label movement-flow-label-btn${fitsInBand ? '' : ' movement-flow-label-hover-reveal'}`}
|
||||||
}}
|
style={{
|
||||||
title={`Open ${layout.movement.name} gallery hall`}
|
left: `${leftPct}%`,
|
||||||
onClick={() => onMovementClick?.(layout.movement.id)}
|
top: `${topPct}%`,
|
||||||
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
}}
|
||||||
onMouseLeave={clearMovementHoverSoon}
|
title={`Open ${layout.movement.name} gallery hall`}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onClick={() => onMovementClick?.(layout.movement.id)}
|
||||||
>
|
onMouseEnter={() => setMovementHover(layout.movement.id)}
|
||||||
<span className="movement-name">{layout.movement.name}</span>
|
onMouseLeave={clearMovementHoverSoon}
|
||||||
{layout.movement.era_name && (
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
<span className="movement-era">{layout.movement.era_name}</span>
|
>
|
||||||
)}
|
<span className="movement-name">{layout.movement.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="movements-flow-artists">
|
<div className="movements-flow-artists">
|
||||||
|
|||||||
Reference in New Issue
Block a user