refactor(helix): nodes on rungs, not strand peaks

Each project's node now sits at the rung centre (x = CX) instead of at
its strand's instantaneous extreme. The "line in between" the strands
becomes the natural place for the project marker — true to the
base-pair metaphor — and frees Y positions from having to coincide
with strand-peak intervals.

What this unlocks
- Slot Y is decoupled from the wave period. SLOT_HEIGHT drops from
  240 → 180 and the wave period from SLOT_HEIGHT*2 → a fixed 320,
  giving the helix a denser twist without re-spacing the cards.
- Cards pack tighter (TOP/BOTTOM padding 120 → 80, summary clamp
  3 → 2 lines, padding shaved). 3 projects now fit in ~620 px of
  helix section instead of ~960 px — empty space goes away.

Visual additions
- Per-project base-pair: a prominent strand-coloured line at each
  node's Y, drawn from strand A's x to strand B's x. This is the
  rung the node sits on.
- Connector dash line continues from the node centre outward to the
  card edge.

Side mapping
- Strand identity is now purely the card's side + stripe + badge:
  A → left, B → right. (Previously the side followed the strand's
  instantaneous extreme, which gave inconsistent groupings.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-20 12:29:49 +02:00
parent 241411054e
commit 9619c93bd8

View File

@@ -39,12 +39,15 @@
// Geometry (user-space units inside the SVG)
const W = 1000;
const CX = W / 2;
const AMP = 140;
const TOP_PAD = 120;
const BOTTOM_PAD = 120;
const SLOT_HEIGHT = 240;
const AMP = 130;
const TOP_PAD = 80;
const BOTTOM_PAD = 80;
const SLOT_HEIGHT = 180;
const PERIOD = SLOT_HEIGHT * 2;
// Wave period decoupled from slot spacing — nodes are at rung centers, so
// they don't need to line up with strand extremes. Pick a period that
// gives the helix a pleasant twist density.
const PERIOD = 320;
const k = (2 * Math.PI) / PERIOD;
type Rung = { y: number; x1: number; x2: number; depth: number; aFront: boolean };
@@ -52,6 +55,11 @@
project: StrandProject;
index: number;
y: number;
/** strand A x at the slot's y (where the node's rung meets strand A) */
strandAx: number;
/** strand B x at the slot's y (where the node's rung meets strand B) */
strandBx: number;
/** node always sits at the rung centre (x = CX) */
nodeX: number;
side: 'left' | 'right';
};
@@ -98,15 +106,21 @@
const slots = $derived<Slot[]>(
projects.map((p, i) => {
const y = TOP_PAD + i * SLOT_HEIGHT;
const sign = p.strand === 'A' ? 1 : -1;
const nodeX = CX + sign * AMP * Math.sin(k * y);
// Y positions are slot-based but no longer constrained by the wave
// period — nodes sit on rungs (x = CX) at any Y. Tighten the spacing
// to let cards pack dynamically.
const y = TOP_PAD + i * SLOT_HEIGHT + SLOT_HEIGHT / 2;
const sA = Math.sin(k * y);
return {
project: p,
index: i,
y,
nodeX,
side: nodeX > CX ? 'right' : 'left'
strandAx: CX + AMP * sA,
strandBx: CX - AMP * sA,
nodeX: CX,
// Side is determined by strand identity, not geometry.
// Strand A (Projects) → left, Strand B (Innovations) → right.
side: p.strand === 'A' ? 'left' : 'right'
};
})
);
@@ -211,7 +225,37 @@
filter="url(#vhelix-glow)"
/>
<!-- Project nodes -->
<!-- Per-project base-pair rungs: strand-to-strand line at the node's Y,
drawn in the project's strand colour. This is the "line in between"
the strands that each node sits on. -->
{#each slots as s}
<line
x1={s.strandAx}
y1={s.y}
x2={s.strandBx}
y2={s.y}
stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.75"
stroke-width="2.4"
stroke-linecap="round"
/>
{/each}
<!-- Connector lines: from the node (rung centre) outward to the card edge -->
{#each slots as s}
<line
x1={s.nodeX}
y1={s.y}
x2={s.side === 'right' ? W - 70 : 70}
y2={s.y}
stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.35"
stroke-width="1"
stroke-dasharray="2 4"
/>
{/each}
<!-- Project nodes — always at rung centre (x = CX) -->
{#each slots as s}
<g class="vnode" data-strand={s.project.strand}>
<circle
@@ -238,20 +282,6 @@
/>
</g>
{/each}
<!-- Connector dash lines from node to card -->
{#each slots as s}
<line
x1={s.nodeX}
y1={s.y}
x2={s.side === 'right' ? W - 80 : 80}
y2={s.y}
stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.32"
stroke-width="1"
stroke-dasharray="2 4"
/>
{/each}
</svg>
<!-- HTML lab-slide cards layered over the SVG -->
@@ -331,8 +361,8 @@
.slide {
position: absolute;
width: 300px;
max-width: calc(50% - 90px);
padding: 1.1rem 1.25rem 0.95rem;
max-width: calc(50% - 60px);
padding: 0.95rem 1.15rem 0.85rem;
border-radius: 12px;
background: color-mix(in oklab, var(--color-helix-bg-2) 88%, transparent);
border: 1px solid var(--color-helix-border);
@@ -455,12 +485,12 @@
.summary {
color: var(--color-helix-ink-dim);
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 0.7rem;
font-size: 0.88rem;
line-height: 1.45;
margin: 0 0 0.55rem;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}