Skip to content

Commit 8538878

Browse files
committed
feat(bodygraph): name each centre as ground behind its gate numbers
1 parent 154d826 commit 8538878

5 files changed

Lines changed: 119 additions & 3 deletions

File tree

396 Bytes
Loading
384 Bytes
Loading

packages/ui/src/utils/bodygraph-render.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ interface CenterGeometry {
3636
label: string;
3737
color: CenterColor;
3838
points: Point[];
39+
/** Where the centre name sits, when the shape's middle is taken by a gate number. Defaults to the centroid. */
40+
labelAt?: Point;
3941
}
4042

4143
/** Traditional center color group: gold for Head and G, green for Ajna, red for the Heart and Sacral motors, brown for the rest. A defined center is filled with it; an open one takes the card surface so the wiring passes behind. */
@@ -65,6 +67,8 @@ const mirrorX = (x: number): number => 2 * AXIS - x;
6567
/** Gate circle and number size, in grid units. Bounded by the closest pair on the chart (Throat 12 and 45, 2.3 apart) and by fitting two digits inside the circle. */
6668
export const GATE_RADIUS = 1.02 * UNIT;
6769
export const GATE_FONT_SIZE = 1.35 * UNIT;
70+
/** The centre name behind the numbers. Smaller than a gate number so it reads as ground rather than as another value. */
71+
export const CENTER_NAME_FONT_SIZE = 1.15 * UNIT;
6872

6973
/** The three x offsets from {@link AXIS} a central-column gate sits on: the inner triples, the outer column of the square centers, and the G's wider waist pair. Named so they cannot drift apart one edit at a time. */
7074
const COL = 2.87;
@@ -478,14 +482,78 @@ function renderCenters(
478482
names: ReadonlyMap<BodygraphCenterId, string> | undefined,
479483
stateWords: { defined: string; open: string },
480484
): TemplateResult[] {
481-
return CENTER_GEOMETRY.map((c) => {
485+
return CENTER_GEOMETRY.flatMap((c) => {
482486
const isDefined = defined.has(c.id);
483487
const cls = `bg-center bg-${c.color}${isDefined ? ' defined' : ''}`;
484488
const label = names?.get(c.id) || c.label;
485-
return svg`<polygon class=${cls} points=${polygonPoints(c.points)}><title>${label}: ${isDefined ? stateWords.defined : stateWords.open}</title></polygon>`;
489+
const at = c.labelAt ?? centroid(c.points);
490+
return [
491+
svg`<polygon class=${cls} points=${polygonPoints(c.points)}><title>${label}: ${isDefined ? stateWords.defined : stateWords.open}</title></polygon>`,
492+
// Drawn with the shapes, so the gate numbers paint over it. Hidden from
493+
// the accessibility tree because the polygon `<title>` above already
494+
// names this centre, and announcing it twice is worse than once.
495+
renderCenterName(label, at, isDefined, c.points),
496+
];
486497
});
487498
}
488499

500+
/**
501+
* The centre name as ground behind the gate numbers.
502+
*
503+
* @remarks
504+
* A multi-word name stacks one word per line rather than running past the shape: the two side triangles taper to a point, so `Solar Plexus` on one line overruns its own outline while `SOLAR` over `PLEXUS` sits inside it. Splitting on whitespace is one rule for every language rather than a width measured per label, and a name that is one long word in some language simply stays one line.
505+
*/
506+
function renderCenterName(
507+
label: string,
508+
at: Point,
509+
isDefined: boolean,
510+
points: readonly Point[],
511+
): TemplateResult {
512+
const words = label.split(/\s+/).filter(Boolean);
513+
const longest = words.reduce((n, w) => Math.max(n, w.length), 0);
514+
// Shrink to the room the shape actually has at this height rather than
515+
// trusting one size to fit every name: the side centres taper to a point, and
516+
// a translated name is a different length in every language.
517+
const room = widthAt(points, at.y) * 0.86;
518+
const size = Math.min(
519+
CENTER_NAME_FONT_SIZE,
520+
room / Math.max(1, longest * UPPERCASE_EM),
521+
);
522+
const step = size * 1.05;
523+
const top = at.y - (step * (words.length - 1)) / 2;
524+
return svg`<text class="bg-center-name ${isDefined ? 'defined' : ''}" style=${`font-size:${size}px`} x=${at.x} y=${at.y} text-anchor="middle" dominant-baseline="central" aria-hidden="true">${words.map(
525+
(w, i) => svg`<tspan x=${at.x} y=${top + i * step}>${w}</tspan>`,
526+
)}</text>`;
527+
}
528+
529+
/** Advance width of one uppercase character at the label weight, as a fraction of the font size. Approximate on purpose: it only has to keep a name inside its own outline, and it is applied with room to spare. */
530+
const UPPERCASE_EM = 0.66;
531+
532+
/** Horizontal span of a convex polygon at height `y`: how much room a line of text has there. */
533+
function widthAt(points: readonly Point[], y: number): number {
534+
const xs: number[] = [];
535+
for (let i = 0; i < points.length; i++) {
536+
const a = points[i] as Point;
537+
const b = points[(i + 1) % points.length] as Point;
538+
if (a.y === b.y) continue;
539+
const lo = Math.min(a.y, b.y);
540+
const hi = Math.max(a.y, b.y);
541+
if (y < lo || y > hi) continue;
542+
xs.push(a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x));
543+
}
544+
if (xs.length < 2) return 0;
545+
return Math.max(...xs) - Math.min(...xs);
546+
}
547+
548+
/** Average of a shape's vertices: the visual middle of every centre this chart draws, all of which are convex. */
549+
function centroid(points: readonly Point[]): Point {
550+
const n = points.length || 1;
551+
return {
552+
x: points.reduce((t, p) => t + p.x, 0) / n,
553+
y: points.reduce((t, p) => t + p.y, 0) / n,
554+
};
555+
}
556+
489557
/** A half-disc starting at the top of the circle and sweeping to the bottom. `sweep` 1 is the right half. */
490558
function halfDisc(p: Point, sweep: 0 | 1): string {
491559
return `M ${p.x} ${p.y - GATE_RADIUS} A ${GATE_RADIUS} ${GATE_RADIUS} 0 0 ${sweep} ${p.x} ${p.y + GATE_RADIUS} Z`;

packages/ui/src/utils/bodygraph-styles.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { css } from 'lit';
2-
import { GATE_FONT_SIZE } from './bodygraph-render.js';
2+
import { CENTER_NAME_FONT_SIZE, GATE_FONT_SIZE } from './bodygraph-render.js';
33

44
/**
55
* The stylesheet for anything `renderBodygraphSvg` draws, so a second chart on the same geometry cannot fork the first one's appearance.
@@ -106,6 +106,22 @@ export const bodygraphChartStyles = css`
106106
.bg-gate-dot.on {
107107
stroke: none;
108108
}
109+
/* The centre name, drawn as ground behind the gate numbers. Muted enough to
110+
* sit under them and never compete, and it takes the knocked-out ink on a
111+
* filled centre so it stays readable on the saturated fills. */
112+
.bg-center-name {
113+
fill: var(--roxy-muted, #71717a);
114+
opacity: 0.5;
115+
font-size: ${CENTER_NAME_FONT_SIZE}px;
116+
font-weight: 600;
117+
letter-spacing: 0.08em;
118+
text-transform: uppercase;
119+
pointer-events: none;
120+
}
121+
.bg-center-name.defined {
122+
fill: var(--roxy-surface, #fff);
123+
opacity: 0.62;
124+
}
109125
.bg-gate {
110126
fill: var(--roxy-muted, #71717a);
111127
font-size: ${GATE_FONT_SIZE}px;

packages/ui/tests/e2e/parts.e2e.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,35 @@ test('a component renders no part name the catalog does not publish', async ({
229229

230230
expect(surprises).toEqual([]);
231231
});
232+
233+
/**
234+
* Every centre name stays inside the shape it names.
235+
*
236+
* @remarks
237+
* The names are ground behind the gate numbers, so a name wider than its own outline reads as text spilling onto the chart rather than as a label. It cannot be checked without a browser: the width depends on the rendered font, and the names come from the response, so a translation is a different length in every language. The renderer shrinks a name to the room the shape has at that height, and this is the assertion that makes that non-vacuous.
238+
*/
239+
test('no centre name spills outside the centre it names', async ({ page }) => {
240+
await showcase(page);
241+
const spills = await page.evaluate(() => {
242+
const root = document.querySelector('roxy-bodygraph')?.shadowRoot;
243+
if (!root) return ['roxy-bodygraph did not mount'];
244+
const polys = [...root.querySelectorAll('polygon')].filter((n) =>
245+
n.querySelector('title'),
246+
);
247+
const names = [...root.querySelectorAll('.bg-center-name')];
248+
if (names.length !== polys.length) {
249+
return [`${names.length} names against ${polys.length} centres`];
250+
}
251+
const out: string[] = [];
252+
names.forEach((t, i) => {
253+
const a = (t as SVGGraphicsElement).getBBox();
254+
const b = (polys[i] as SVGGraphicsElement).getBBox();
255+
// Half a unit of slack for the antialiased edge of the outline itself.
256+
if (a.x < b.x - 0.5 || a.x + a.width > b.x + b.width + 0.5) {
257+
out.push(`${t.textContent?.trim()} is wider than its centre`);
258+
}
259+
});
260+
return out;
261+
});
262+
expect(spills).toEqual([]);
263+
});

0 commit comments

Comments
 (0)