feat: the story thread looks like a track on a circuit board
Owner: the direction is right, but a smooth line is not developer style; everything else on the site is binary. The thread is now a trace: straight runs with 45 degree bends, solder points where it bends or meets a figure, a second fainter track beside it, figures as components (pins on the rectangular ones, the chapter number in binary inside), and a bit that flips between 1 and 0 as it travels at the head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,9 +65,10 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
|
|||||||
`data-glow` surfaces). Reuse them instead of inventing new effects.
|
`data-glow` surfaces). Reuse them instead of inventing new effects.
|
||||||
- The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens
|
- The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens
|
||||||
with one sentence from `story` in the content files; read one after the other they tell the way of a
|
with one sentence from `story` in the content files; read one after the other they tell the way of a
|
||||||
project from the idea to the first email. `StoryThread` is one line down the whole page that draws itself
|
project from the idea to the first email. `StoryThread` is one trace down the whole page, in the look of a circuit
|
||||||
as the visitor scrolls: under each sentence it crosses the page, stands a figure on the line and continues
|
board (45 degree bends, solder points, a second parallel track), that draws itself as the visitor scrolls:
|
||||||
on the other side, with a glowing dot at its head. `Block` prop `chapter` gives a section its sentence and
|
under each sentence it crosses the page, stands a component with the chapter number in binary on the track
|
||||||
|
and continues on the other side, with a bit (1/0) travelling at its head. `Block` prop `chapter` gives a section its sentence and
|
||||||
figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage
|
figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage
|
||||||
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
|
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
|
||||||
(owner rejected both).
|
(owner rejected both).
|
||||||
|
|||||||
+58
-19
@@ -1340,31 +1340,70 @@ pre:has(code[class*="language-"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The story thread (see StoryThread): one line down the page, indigo on light
|
* The story thread (see StoryThread): a trace down the page like a track on a
|
||||||
* sections and light on dark ones, with a glowing dot at its head.
|
* circuit board. Indigo on light sections, light on dark ones.
|
||||||
*/
|
*/
|
||||||
.story-thread path {
|
.story-thread {
|
||||||
stroke-width: 1.5;
|
--thread-ink: #4f46e5;
|
||||||
stroke-linecap: round;
|
--thread-ground: #ffffff;
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
}
|
||||||
.story-thread-light {
|
.story-thread-on-light {
|
||||||
stroke: #4f46e5;
|
--thread-ink: #4f46e5;
|
||||||
|
--thread-ground: #ffffff;
|
||||||
}
|
}
|
||||||
.story-thread-dark {
|
.story-thread-on-dark {
|
||||||
stroke: #c7d2fe;
|
--thread-ink: #c7d2fe;
|
||||||
stroke-width: 2;
|
--thread-ground: #0f172a;
|
||||||
}
|
}
|
||||||
.story-thread-dot {
|
.story-thread path,
|
||||||
width: 12px;
|
.story-thread line {
|
||||||
height: 12px;
|
stroke: var(--thread-ink);
|
||||||
margin: -6px 0 0 -6px;
|
stroke-width: 1.75;
|
||||||
border-radius: 9999px;
|
stroke-linecap: square;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
}
|
||||||
|
/* The second track of the bus. */
|
||||||
|
.story-thread path.story-thread-bus {
|
||||||
|
stroke-width: 1;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
/* Solder points where the track bends or branches. */
|
||||||
|
.story-thread-pad {
|
||||||
|
fill: var(--thread-ground);
|
||||||
|
stroke: var(--thread-ink);
|
||||||
|
stroke-width: 1.75;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 250ms ease;
|
||||||
|
}
|
||||||
|
/* What makes a figure a component: its pins and its number in binary. */
|
||||||
|
.story-thread-part {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 500ms ease 350ms;
|
||||||
|
}
|
||||||
|
.story-thread-part text {
|
||||||
|
fill: var(--thread-ink);
|
||||||
|
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
/* The bit that travels at the head of the track. */
|
||||||
|
.story-thread-bit {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: -9px 0 0 -9px;
|
||||||
|
border-radius: 3px;
|
||||||
background: #fde68a;
|
background: #fde68a;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 4px rgba(253, 230, 138, 0.28),
|
0 0 0 3px rgba(253, 230, 138, 0.25),
|
||||||
0 0 18px 5px rgba(251, 191, 36, 0.85),
|
0 0 18px 4px rgba(251, 191, 36, 0.8),
|
||||||
0 0 44px 14px rgba(129, 140, 248, 0.55);
|
0 0 40px 12px rgba(129, 140, 248, 0.5);
|
||||||
transition: opacity 300ms ease;
|
transition: opacity 300ms ease;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread";
|
import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread";
|
||||||
import { threadPath, type Box, type Figure, type Stop } from "./threadPath";
|
import {
|
||||||
|
FIGURE_CENTRE,
|
||||||
|
FIGURE_PINS,
|
||||||
|
threadPath,
|
||||||
|
type Box,
|
||||||
|
type Figure,
|
||||||
|
type Point,
|
||||||
|
type Stop,
|
||||||
|
} from "./threadPath";
|
||||||
|
|
||||||
/** Marks the box a chapter's figure stands in; the value is the figure. */
|
/** Marks the box a chapter's figure stands in; the value is the figure. */
|
||||||
export const FIGURE_ATTRIBUTE = "data-thread-figure";
|
export const FIGURE_ATTRIBUTE = "data-thread-figure";
|
||||||
@@ -18,12 +26,31 @@ const READING_LINE = 0.66;
|
|||||||
/** How quickly the drawing catches up with the scroll position, per frame. */
|
/** How quickly the drawing catches up with the scroll position, per frame. */
|
||||||
const FOLLOW = 0.075;
|
const FOLLOW = 0.075;
|
||||||
const CONTENT_WIDTH = 1400;
|
const CONTENT_WIDTH = 1400;
|
||||||
|
/** Offset of the second, parallel trace. */
|
||||||
|
const BUS_OFFSET = 5;
|
||||||
|
const PAD_RADIUS = 4;
|
||||||
|
/** The bit at the head of the trace flips after this much travel, in px. */
|
||||||
|
const FLIP_EVERY = 46;
|
||||||
|
/** The first chapter with a figure is the second of the page. */
|
||||||
|
const FIRST_CHAPTER = 2;
|
||||||
|
|
||||||
|
type Mark = { readonly x: number; readonly y: number; readonly dark: boolean };
|
||||||
|
|
||||||
|
type Part = {
|
||||||
|
readonly figure: Figure;
|
||||||
|
readonly box: Box;
|
||||||
|
readonly dark: boolean;
|
||||||
|
/** The chapter number as four binary digits. */
|
||||||
|
readonly label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Layout = {
|
type Layout = {
|
||||||
readonly width: number;
|
readonly width: number;
|
||||||
readonly height: number;
|
readonly height: number;
|
||||||
readonly d: string;
|
readonly d: string;
|
||||||
readonly dark: readonly Box[];
|
readonly dark: readonly Box[];
|
||||||
|
readonly pads: readonly Mark[];
|
||||||
|
readonly parts: readonly Part[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIGURES: readonly Figure[] = [
|
const FIGURES: readonly Figure[] = [
|
||||||
@@ -37,18 +64,20 @@ const FIGURES: readonly Figure[] = [
|
|||||||
const isFigure = (value: string | null): value is Figure =>
|
const isFigure = (value: string | null): value is Figure =>
|
||||||
FIGURES.some((figure) => figure === value);
|
FIGURES.some((figure) => figure === value);
|
||||||
|
|
||||||
|
const inside = (box: Box, x: number, y: number): boolean =>
|
||||||
|
x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StoryThread: one line of light that runs down the whole homepage and draws
|
* StoryThread: one trace that runs down the whole homepage like a track on a
|
||||||
* itself as the visitor scrolls. Under each chapter's sentence it crosses the
|
* circuit board and draws itself as the visitor scrolls. Under each chapter's
|
||||||
* page, stands a figure on the line, and continues on the other side, so the
|
* sentence it crosses the page, stands a component on the track, and continues
|
||||||
* eye is led from chapter to chapter. A glowing dot travels at its head.
|
* on the other side, so the eye is led from chapter to chapter. Solder points
|
||||||
|
* mark its bends, a second track runs beside it, and a bit travels at its head.
|
||||||
* Put it last inside the `relative` wrapper that holds the sections.
|
* Put it last inside the `relative` wrapper that holds the sections.
|
||||||
*/
|
*/
|
||||||
export const StoryThread: React.FC = () => {
|
export const StoryThread: React.FC = () => {
|
||||||
const svgRef = React.useRef<SVGSVGElement>(null);
|
const svgRef = React.useRef<SVGSVGElement>(null);
|
||||||
const lightRef = React.useRef<SVGPathElement>(null);
|
const bitRef = React.useRef<HTMLSpanElement>(null);
|
||||||
const darkRef = React.useRef<SVGPathElement>(null);
|
|
||||||
const dotRef = React.useRef<HTMLSpanElement>(null);
|
|
||||||
const clipId = React.useId();
|
const clipId = React.useId();
|
||||||
const [layout, setLayout] = React.useState<Layout | null>(null);
|
const [layout, setLayout] = React.useState<Layout | null>(null);
|
||||||
|
|
||||||
@@ -77,8 +106,7 @@ export const StoryThread: React.FC = () => {
|
|||||||
if (isFigure(figure) && box.width > 0) stops.push({ figure, box });
|
if (isFigure(figure) && box.width > 0) stops.push({ figure, box });
|
||||||
});
|
});
|
||||||
const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`);
|
const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`);
|
||||||
const first = stops[0];
|
if (!startSection || stops.length === 0) {
|
||||||
if (!startSection || !first) {
|
|
||||||
setLayout(null);
|
setLayout(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,20 +118,37 @@ export const StoryThread: React.FC = () => {
|
|||||||
const right = Math.round(origin.width - margin);
|
const right = Math.round(origin.width - margin);
|
||||||
const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`);
|
const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`);
|
||||||
const endBox = endElement ? boxOf(endElement) : null;
|
const endBox = endElement ? boxOf(endElement) : null;
|
||||||
|
const dark = Array.from(
|
||||||
|
wrapper.querySelectorAll("section.bg-slate-900"),
|
||||||
|
).map(boxOf);
|
||||||
|
const isDark = (x: number, y: number): boolean =>
|
||||||
|
dark.some((box) => inside(box, x, y));
|
||||||
|
const thread = threadPath({
|
||||||
|
start: [left, boxOf(startSection).y + 1],
|
||||||
|
stops,
|
||||||
|
end: endBox ? [endBox.x, endBox.y] : null,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
});
|
||||||
|
|
||||||
setLayout({
|
setLayout({
|
||||||
width: origin.width,
|
width: origin.width,
|
||||||
height: origin.height,
|
height: origin.height,
|
||||||
d: threadPath({
|
d: thread.d,
|
||||||
start: [left, boxOf(startSection).y],
|
dark,
|
||||||
stops,
|
pads: thread.pads.map(([x, y]: Point) => ({
|
||||||
end: endBox ? [endBox.x, endBox.y] : null,
|
x,
|
||||||
left,
|
y,
|
||||||
right,
|
dark: isDark(x, y),
|
||||||
}),
|
})),
|
||||||
dark: Array.from(wrapper.querySelectorAll("section.bg-slate-900")).map(
|
parts: stops.map((stop, index) => ({
|
||||||
boxOf,
|
...stop,
|
||||||
),
|
dark: isDark(
|
||||||
|
stop.box.x + stop.box.width / 2,
|
||||||
|
stop.box.y + stop.box.height / 2,
|
||||||
|
),
|
||||||
|
label: (index + FIRST_CHAPTER).toString(2).padStart(4, "0"),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,36 +165,52 @@ export const StoryThread: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Draw as far as the visitor has read, and move the dot to the head of the line.
|
// Draw as far as the visitor has read, and move the bit to the head of the trace.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const svg = svgRef.current;
|
const svg = svgRef.current;
|
||||||
const light = lightRef.current;
|
const bit = bitRef.current;
|
||||||
const dark = darkRef.current;
|
if (!layout || !svg || !bit) return;
|
||||||
const dot = dotRef.current;
|
const traces = Array.from(
|
||||||
if (!layout || !svg || !light || !dark || !dot) return;
|
svg.querySelectorAll<SVGPathElement>("path[data-trace]"),
|
||||||
|
);
|
||||||
|
const main = traces[0];
|
||||||
|
if (!main) return;
|
||||||
|
|
||||||
const total = light.getTotalLength();
|
const total = main.getTotalLength();
|
||||||
const ys: number[] = [];
|
const points: DOMPoint[] = [];
|
||||||
for (let at = 0; at <= total; at += SAMPLE_STEP) {
|
for (let at = 0; at <= total; at += SAMPLE_STEP) {
|
||||||
ys.push(light.getPointAtLength(at).y);
|
points.push(main.getPointAtLength(at));
|
||||||
}
|
|
||||||
const depths = deepestSoFar(ys);
|
|
||||||
for (const path of [light, dark]) {
|
|
||||||
path.style.strokeDasharray = `${total}`;
|
|
||||||
}
|
}
|
||||||
|
const depths = deepestSoFar(points.map((point) => point.y));
|
||||||
|
for (const trace of traces) trace.style.strokeDasharray = `${total}`;
|
||||||
|
|
||||||
|
// Solder points and components appear once the trace has reached them.
|
||||||
|
const reached = Array.from(
|
||||||
|
svg.querySelectorAll<SVGElement>("[data-at-x]"),
|
||||||
|
).map((element) => {
|
||||||
|
const x = Number(element.dataset.atX);
|
||||||
|
const y = Number(element.dataset.atY);
|
||||||
|
const distances = points.map((point) =>
|
||||||
|
Math.hypot(point.x - x, point.y - y),
|
||||||
|
);
|
||||||
|
const nearest = distances.indexOf(Math.min(...distances));
|
||||||
|
return { element, length: nearest * SAMPLE_STEP };
|
||||||
|
});
|
||||||
|
|
||||||
const show = (length: number) => {
|
const show = (length: number) => {
|
||||||
const offset = `${total - length}`;
|
const offset = `${total - length}`;
|
||||||
light.style.strokeDashoffset = offset;
|
for (const trace of traces) trace.style.strokeDashoffset = offset;
|
||||||
dark.style.strokeDashoffset = offset;
|
for (const mark of reached) {
|
||||||
const head = light.getPointAtLength(length);
|
mark.element.style.opacity = length + 2 >= mark.length ? "1" : "0";
|
||||||
dot.style.transform = `translate(${head.x}px, ${head.y}px)`;
|
}
|
||||||
dot.style.opacity = length > 0 && length < total ? "1" : "0";
|
const head = main.getPointAtLength(length);
|
||||||
|
bit.style.transform = `translate(${head.x}px, ${head.y}px)`;
|
||||||
|
bit.style.opacity = length > 0 && length < total - 1 ? "1" : "0";
|
||||||
|
bit.textContent = Math.floor(length / FLIP_EVERY) % 2 === 0 ? "1" : "0";
|
||||||
};
|
};
|
||||||
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
show(total);
|
show(total);
|
||||||
dot.style.opacity = "0";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +233,9 @@ export const StoryThread: React.FC = () => {
|
|||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [layout]);
|
}, [layout]);
|
||||||
|
|
||||||
|
const tone = (dark: boolean) =>
|
||||||
|
dark ? "story-thread-on-dark" : "story-thread-on-light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<svg
|
<svg
|
||||||
@@ -191,20 +255,89 @@ export const StoryThread: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
<path ref={lightRef} d={layout.d} className="story-thread-light" />
|
{/* The main track first: it is the one that is measured. */}
|
||||||
<path
|
<path
|
||||||
ref={darkRef}
|
data-trace=""
|
||||||
d={layout.d}
|
d={layout.d}
|
||||||
className="story-thread-dark"
|
className="story-thread-on-light"
|
||||||
clipPath={`url(#${clipId})`}
|
|
||||||
/>
|
/>
|
||||||
|
<path
|
||||||
|
data-trace=""
|
||||||
|
d={layout.d}
|
||||||
|
className="story-thread-on-light story-thread-bus"
|
||||||
|
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
|
||||||
|
/>
|
||||||
|
<g clipPath={`url(#${clipId})`}>
|
||||||
|
<path
|
||||||
|
data-trace=""
|
||||||
|
d={layout.d}
|
||||||
|
className="story-thread-on-dark"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
data-trace=""
|
||||||
|
d={layout.d}
|
||||||
|
className="story-thread-on-dark story-thread-bus"
|
||||||
|
transform={`translate(${BUS_OFFSET} ${BUS_OFFSET})`}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{layout.parts.map((part) => {
|
||||||
|
const place = ([ux, uy]: Point): Point => [
|
||||||
|
part.box.x + (ux / 100) * part.box.width,
|
||||||
|
part.box.y + (uy / 100) * part.box.height,
|
||||||
|
];
|
||||||
|
const [labelX, labelY] = place(FIGURE_CENTRE[part.figure]);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={part.label}
|
||||||
|
data-at-x={part.box.x + part.box.width / 2}
|
||||||
|
data-at-y={part.box.y + part.box.height}
|
||||||
|
className={`story-thread-part ${tone(part.dark)}`}
|
||||||
|
>
|
||||||
|
{FIGURE_PINS[part.figure].map(([from, to]) => {
|
||||||
|
const [x1, y1] = place(from);
|
||||||
|
const [x2, y2] = place(to);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`${from[0]}-${from[1]}-${to[0]}`}
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text
|
||||||
|
x={labelX}
|
||||||
|
y={labelY}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={Math.max(9, part.box.width * 0.105)}
|
||||||
|
>
|
||||||
|
{part.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{layout.pads.map((pad) => (
|
||||||
|
<circle
|
||||||
|
key={`${pad.x}-${pad.y}`}
|
||||||
|
data-at-x={pad.x}
|
||||||
|
data-at-y={pad.y}
|
||||||
|
cx={pad.x}
|
||||||
|
cy={pad.y}
|
||||||
|
r={PAD_RADIUS}
|
||||||
|
className={`story-thread-pad ${tone(pad.dark)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
ref={dotRef}
|
ref={bitRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="story-thread-dot pointer-events-none absolute left-0 top-0 z-10 opacity-0"
|
className="story-thread-bit pointer-events-none absolute left-0 top-0 z-10 opacity-0"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Geometry of the story thread: one continuous line down the page. At every
|
* Geometry of the story thread: one continuous trace down the page. At every
|
||||||
* chapter it runs across the full width under the chapter's sentence, stands a
|
* chapter it runs across the full width under the chapter's sentence, stands a
|
||||||
* figure on that line, and continues down the other side.
|
* figure on that line, and continues down the other side.
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ export type Stop = {
|
|||||||
readonly box: Box;
|
readonly box: Box;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Point = readonly [number, number];
|
export type Point = readonly [number, number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closed figures in a 100 x 100 box, starting and ending at the bottom centre
|
* Closed figures in a 100 x 100 box, starting and ending at the bottom centre
|
||||||
@@ -93,8 +93,15 @@ const figurePath = (figure: Figure, box: Box, direction: 1 | -1): string => {
|
|||||||
return POLYGONS[figure].map((point) => `L${place(point)}`).join("");
|
return POLYGONS[figure].map((point) => `L${place(point)}`).join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Thread = {
|
||||||
|
readonly d: string;
|
||||||
|
/** Where the trace branches or turns: the solder points, in the order the line passes them. */
|
||||||
|
readonly pads: readonly Point[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole line. It starts at `start` in the left margin, visits the stops in
|
* The whole line, laid out like a trace on a circuit board: straight runs and
|
||||||
|
* 45 degree bends. It starts at `start` in the left margin, visits the stops in
|
||||||
* order (arriving from the left at the first, from the right at the second, and
|
* order (arriving from the left at the first, from the right at the second, and
|
||||||
* so on), and ends at `end`. `left` and `right` are the x of the two margins.
|
* so on), and ends at `end`. `left` and `right` are the x of the two margins.
|
||||||
*/
|
*/
|
||||||
@@ -104,8 +111,9 @@ export const threadPath = (thread: {
|
|||||||
readonly end: Point | null;
|
readonly end: Point | null;
|
||||||
readonly left: number;
|
readonly left: number;
|
||||||
readonly right: number;
|
readonly right: number;
|
||||||
}): string => {
|
}): Thread => {
|
||||||
const { left, right } = thread;
|
const { left, right } = thread;
|
||||||
|
const pads: Point[] = [thread.start];
|
||||||
let d = `M${round(thread.start[0])} ${round(thread.start[1])}`;
|
let d = `M${round(thread.start[0])} ${round(thread.start[1])}`;
|
||||||
let side = left;
|
let side = left;
|
||||||
|
|
||||||
@@ -116,16 +124,53 @@ export const threadPath = (thread: {
|
|||||||
const y = round(stop.box.y + stop.box.height);
|
const y = round(stop.box.y + stop.box.height);
|
||||||
const foot = round(stop.box.x + stop.box.width / 2);
|
const foot = round(stop.box.x + stop.box.width / 2);
|
||||||
d +=
|
d +=
|
||||||
`L${from} ${y - CORNER}Q${from} ${y} ${from + direction * CORNER} ${y}` +
|
`L${from} ${y - CORNER}L${from + direction * CORNER} ${y}` +
|
||||||
`L${foot} ${y}` +
|
`L${foot} ${y}` +
|
||||||
figurePath(stop.figure, stop.box, direction) +
|
figurePath(stop.figure, stop.box, direction) +
|
||||||
`L${to - direction * CORNER} ${y}Q${to} ${y} ${to} ${y + CORNER}`;
|
`L${to - direction * CORNER} ${y}L${to} ${y + CORNER}`;
|
||||||
|
pads.push([from, y - CORNER], [foot, y], [to, y + CORNER]);
|
||||||
side = to;
|
side = to;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (thread.end) {
|
if (thread.end) {
|
||||||
const [x, y] = thread.end;
|
const [x, y] = thread.end;
|
||||||
d += `L${side} ${round(y - CORNER)}Q${side} ${round(y)} ${round(x)} ${round(y)}`;
|
const bend = Math.abs(x - side);
|
||||||
|
d += `L${side} ${round(y - bend)}L${round(x)} ${round(y)}`;
|
||||||
|
pads.push([round(x), round(y)]);
|
||||||
}
|
}
|
||||||
return d;
|
return { d, pads };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extra marks that make a figure read as a component: pins, as lines in the 100 x 100 box. */
|
||||||
|
export const FIGURE_PINS: Readonly<
|
||||||
|
Record<Figure, readonly (readonly [Point, Point])[]>
|
||||||
|
> = {
|
||||||
|
diamond: [],
|
||||||
|
hexagon: [],
|
||||||
|
circle: [],
|
||||||
|
triangle: [],
|
||||||
|
screen: [20, 35, 50, 65, 80].map((x) => [
|
||||||
|
[x, 36],
|
||||||
|
[x, 24],
|
||||||
|
]),
|
||||||
|
square: [38, 54, 70, 86].flatMap((y) => [
|
||||||
|
[
|
||||||
|
[10, y],
|
||||||
|
[-2, y],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[90, y],
|
||||||
|
[102, y],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where the label sits inside each figure, in the 100 x 100 box. */
|
||||||
|
export const FIGURE_CENTRE: Readonly<Record<Figure, Point>> = {
|
||||||
|
diamond: [50, 55],
|
||||||
|
hexagon: [50, 57],
|
||||||
|
screen: [50, 68],
|
||||||
|
circle: [50, 55],
|
||||||
|
square: [50, 60],
|
||||||
|
triangle: [50, 74],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user