Skip to content

Commit 7339f43

Browse files
committed
Abstract Mermaid diagram pattern into reusable component
Replaced 3 manual diagram instances with MermaidDiagram component to eliminate ~120 lines of boilerplate. Component provides hover-triggered slide-up interaction, collapsible relationship details with chevron indicator, and WCAG AA accessibility (role="img", aria attributes, keyboard navigation, prefers-reduced-motion support). Why: Consistent UX across all diagrams, reduced maintenance burden, enforced accessibility standards through component architecture.
1 parent 2bc0709 commit 7339f43

File tree

2 files changed

+244
-46
lines changed

2 files changed

+244
-46
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
---
2+
/**
3+
* MermaidDiagram Component
4+
*
5+
* Reusable wrapper for Mermaid diagrams with:
6+
* - WCAG AA accessibility (role="img", figcaption, aria-labelledby)
7+
* - Egyptian design system colors (atomic, derived, system)
8+
* - Hover/focus interaction to reveal text description
9+
* - Slide-up animation (respects prefers-reduced-motion)
10+
*
11+
* @example
12+
* <MermaidDiagram
13+
* id="data-flow"
14+
* caption="Data flow diagram showing atomic events deriving aggregated metrics"
15+
* relationships={[
16+
* "Event leads to metric (temporal sequence)",
17+
* "Metric aggregates event data"
18+
* ]}
19+
* >
20+
* ```mermaid
21+
* graph LR
22+
* A[Event] --> B[Metric]
23+
* classDef atomic fill:#FEF3C7,stroke:#F59E0B
24+
* class A atomic
25+
* ```
26+
* </MermaidDiagram>
27+
*/
28+
29+
interface Props {
30+
/** Unique ID for aria-labelledby (e.g., "data-flow") */
31+
id: string;
32+
/** Screen reader description of diagram content */
33+
caption: string;
34+
/** Array of relationship descriptions (shown on hover) */
35+
relationships: string[];
36+
}
37+
38+
const { id, caption, relationships } = Astro.props;
39+
40+
// Generate unique IDs for accessibility
41+
const figcaptionId = `diagram-${id}`;
42+
const descriptionId = `diagram-${id}-description`;
43+
---
44+
45+
<figure
46+
role="img"
47+
aria-labelledby={figcaptionId}
48+
aria-describedby={descriptionId}
49+
class="diagram-container group relative my-8 bg-surface border border-neutral-light rounded-lg px-6 pb-6 overflow-hidden"
50+
>
51+
<!-- Screen reader caption (visually hidden) -->
52+
<figcaption id={figcaptionId} class="sr-only">
53+
{caption}
54+
</figcaption>
55+
56+
<!-- Mermaid diagram (slot allows remark plugin to process markdown) -->
57+
<div class="mermaid-wrapper">
58+
<slot />
59+
</div>
60+
61+
<!-- Text description (slides up on hover/focus) -->
62+
<div
63+
id={descriptionId}
64+
class="diagram-description"
65+
aria-label="Text description of diagram relationships"
66+
>
67+
<details>
68+
<summary class="description-label">
69+
Text description of relationships
70+
</summary>
71+
<ul class="description-list">
72+
{relationships.map((relationship) => (
73+
<li>{relationship}</li>
74+
))}
75+
</ul>
76+
</details>
77+
</div>
78+
</figure>
79+
80+
<style>
81+
/* Container */
82+
.diagram-container {
83+
transition: border-color 0.2s ease;
84+
}
85+
86+
.diagram-container:hover,
87+
.diagram-container:focus-within {
88+
border-color: var(--color-neutral);
89+
}
90+
91+
/* Mermaid wrapper */
92+
.mermaid-wrapper {
93+
position: relative;
94+
z-index: 1;
95+
}
96+
97+
/* Text description - hidden by default */
98+
.diagram-description {
99+
margin-top: 1rem;
100+
opacity: 0;
101+
transform: translateY(20px);
102+
transition:
103+
opacity 0.3s cubic-bezier(0.65, 0, 0.35, 1),
104+
transform 0.3s cubic-bezier(0.65, 0, 0.35, 1);
105+
pointer-events: none;
106+
}
107+
108+
/* Reveal on hover/focus OR when details is open */
109+
.diagram-container:hover .diagram-description,
110+
.diagram-container:focus-within .diagram-description,
111+
.diagram-description:has(details[open]) {
112+
opacity: 1;
113+
transform: translateY(0);
114+
pointer-events: auto;
115+
}
116+
117+
/* Details container */
118+
details {
119+
margin: 0;
120+
}
121+
122+
/* Description label (summary) */
123+
.description-label {
124+
font-size: 0.75rem; /* text-xs */
125+
font-weight: 400; /* font-normal */
126+
color: var(--color-text-lighter);
127+
cursor: pointer;
128+
transition: color 0.2s ease;
129+
list-style: none; /* Remove default disclosure triangle */
130+
position: relative;
131+
padding-right: 1.25rem; /* Space for chevron */
132+
}
133+
134+
/* Remove default disclosure triangle in WebKit/Blink */
135+
.description-label::-webkit-details-marker {
136+
display: none;
137+
}
138+
139+
/* Chevron indicator */
140+
.description-label::after {
141+
content: '▼';
142+
position: absolute;
143+
right: 0;
144+
font-size: 0.625rem; /* Smaller than text */
145+
transition: transform 0.2s ease;
146+
}
147+
148+
/* Rotate chevron when details is open */
149+
details[open] .description-label::after {
150+
transform: rotate(180deg);
151+
}
152+
153+
/* Darker gray on hover (not accent) */
154+
.diagram-container:hover .description-label,
155+
.diagram-container:focus-within .description-label,
156+
.description-label:hover {
157+
color: var(--color-neutral);
158+
}
159+
160+
/* Description list */
161+
.description-list {
162+
margin-top: 0.5rem;
163+
margin-bottom: 0;
164+
margin-left: 1rem;
165+
font-size: 0.75rem; /* text-xs */
166+
font-weight: 300; /* font-light */
167+
color: var(--color-text-lighter);
168+
list-style-type: disc;
169+
}
170+
171+
.description-list li {
172+
margin-bottom: 0.25rem;
173+
}
174+
175+
.description-list li:last-child {
176+
margin-bottom: 0;
177+
}
178+
179+
/* Respect prefers-reduced-motion */
180+
@media (prefers-reduced-motion: reduce) {
181+
.diagram-description {
182+
transition: none;
183+
opacity: 1;
184+
transform: none;
185+
}
186+
187+
.diagram-container:hover .diagram-description,
188+
.diagram-container:focus-within .diagram-description {
189+
opacity: 1;
190+
transform: none;
191+
}
192+
}
193+
194+
/* Screen reader only class */
195+
.sr-only {
196+
position: absolute;
197+
width: 1px;
198+
height: 1px;
199+
padding: 0;
200+
margin: -1px;
201+
overflow: hidden;
202+
clip: rect(0, 0, 0, 0);
203+
white-space: nowrap;
204+
border-width: 0;
205+
}
206+
</style>

src/pages/portfolio/statsbomb.mdx

Lines changed: 38 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Button from '../../components/Button.astro';
77
import Badge from '../../components/Badge.astro';
88
import Heading from '../../components/typography/Heading.astro';
99
import Body from '../../components/typography/Body.astro';
10+
import MermaidDiagram from '../../components/MermaidDiagram.astro';
1011

1112
<div class="max-w-4xl mx-auto px-4 md:px-6 py-20">
1213
{/* Hero Section */}
@@ -159,10 +160,17 @@ Ensure sequence of groups: [drive-start -> (play-action)+ -> (scoring-play | tur
159160
flow through time, and higher-level facts derive from event patterns.
160161
</Body>
161162
162-
<figure role="img" aria-labelledby="diagram-data-flow" class="my-8 bg-surface border border-neutral-light rounded-lg px-6 pb-6">
163-
<figcaption id="diagram-data-flow" class="sr-only">
164-
Data flow diagram showing atomic events (pass, reception, dribble, shot, block) deriving aggregated metrics (complete-pass, shot-sequence, defensive-action)
165-
</figcaption>
163+
<MermaidDiagram
164+
id="data-flow"
165+
caption="Data flow diagram showing atomic events (pass, reception, dribble, shot, block) deriving aggregated metrics (complete-pass, shot-sequence, defensive-action)"
166+
relationships={[
167+
"Pass event leads to reception event (temporal sequence)",
168+
"Reception leads to dribble, dribble leads to shot, shot leads to block (event chain)",
169+
"Pass and reception combine to create complete-pass metric (derived aggregation)",
170+
"Dribble and shot combine to create shot-sequence metric (derived aggregation)",
171+
"Block event creates defensive-action metric (derived aggregation)"
172+
]}
173+
>
166174
167175
```mermaid
168176
graph LR
@@ -185,17 +193,7 @@ graph LR
185193
class CP,SOT,DA derived
186194
```
187195

188-
<details class="mt-4">
189-
<summary class="cursor-pointer text-xs font-normal text-text-lighter hover:text-accent">Text description of relationships</summary>
190-
<ul class="mt-2 ml-4 text-xs font-light text-text-lighter space-y-1">
191-
<li>Pass event leads to reception event (temporal sequence)</li>
192-
<li>Reception leads to dribble, dribble leads to shot, shot leads to block (event chain)</li>
193-
<li>Pass and reception combine to create complete-pass metric (derived aggregation)</li>
194-
<li>Dribble and shot combine to create shot-sequence metric (derived aggregation)</li>
195-
<li>Block event creates defensive-action metric (derived aggregation)</li>
196-
</ul>
197-
</details>
198-
</figure>
196+
</MermaidDiagram>
199197

200198
<Body size="sm" as="p" class="mb-6 text-neutral italic">
201199
Atomic events (amber/gold) capture "what happened." Derived facts (blue) represent "what it means"—
@@ -247,10 +245,17 @@ graph LR
247245
modeled both temporal relationships (PREV/NEXT) and logical dependencies (DEPENDS_ON).
248246
</Body>
249247

250-
<figure role="img" aria-labelledby="diagram-event-dependency" class="my-8 bg-surface border border-neutral-light rounded-lg px-6 pb-6">
251-
<figcaption id="diagram-event-dependency" class="sr-only">
252-
Event dependency graph showing temporal relationships (solid lines) and logical dependencies (dashed lines) between football events
253-
</figcaption>
248+
<MermaidDiagram
249+
id="event-dependency"
250+
caption="Event dependency graph showing temporal relationships (solid lines) and logical dependencies (dashed lines) between football events"
251+
relationships={[
252+
"Temporal flow (PREV): fifty-fifty → clearance → pass → foul-committed (sequence 1)",
253+
"Logical dependency (DEPENDS_ON): fifty-fifty depends on clearance depends on pass (sequence 2)",
254+
"Temporal flow (PREV): fifty-fifty → clearance → pass (sequence 3)",
255+
"Solid lines represent temporal order (\"what happened next\")",
256+
"Dashed lines represent logical dependencies (\"what this derives from\")"
257+
]}
258+
>
254259

255260
```mermaid
256261
graph TD
@@ -272,17 +277,7 @@ graph TD
272277
linkStyle 3 stroke:#0EA5E9,stroke-width:2px,stroke-dasharray:6 4
273278
```
274279

275-
<details class="mt-4">
276-
<summary class="cursor-pointer text-xs font-normal text-text-lighter hover:text-accent">Text description of relationships</summary>
277-
<ul class="mt-2 ml-4 text-xs font-light text-text-lighter space-y-1">
278-
<li><strong>Temporal flow (PREV):</strong> fifty-fifty → clearance → pass → foul-committed (sequence 1)</li>
279-
<li><strong>Logical dependency (DEPENDS_ON):</strong> fifty-fifty depends on clearance depends on pass (sequence 2)</li>
280-
<li><strong>Temporal flow (PREV):</strong> fifty-fifty → clearance → pass (sequence 3)</li>
281-
<li>Solid lines represent temporal order ("what happened next")</li>
282-
<li>Dashed lines represent logical dependencies ("what this derives from")</li>
283-
</ul>
284-
</details>
285-
</figure>
280+
</MermaidDiagram>
286281

287282
<Body size="sm" as="p" class="mb-6 text-neutral italic">
288283
Solid lines show temporal flow (what happened next). Dashed lines show logical dependencies
@@ -316,10 +311,18 @@ graph TD
316311
full context → resolve once → system cascades to all dependent data.
317312
</Body>
318313

319-
<figure role="img" aria-labelledby="diagram-metadata-claims" class="my-8 bg-surface border border-neutral-light rounded-lg px-6 pb-6">
320-
<figcaption id="diagram-metadata-claims" class="sr-only">
321-
Metadata claims flow showing data sources (collectors, scrapers, APIs) submitting claims that resolve into golden entities with automatic conflict resolution
322-
</figcaption>
314+
<MermaidDiagram
315+
id="metadata-claims"
316+
caption="Metadata claims flow showing data sources (collectors, scrapers, APIs) submitting claims that resolve into golden entities with automatic conflict resolution"
317+
relationships={[
318+
"Data sources (amber): Collector A, Scraped Source, and Third-party API submit nationality claims",
319+
"Claims (blue): \"Nationality: Egypt\" and \"Nationality: UK\" represent conflicting data",
320+
"Conflict resolution: Conflicting claim (Nationality: UK) routes to Pending Conflicts Queue",
321+
"System nodes (emerald): Metadata Team Review resolves conflicts and updates Golden Entity",
322+
"Cascade: Golden Entity (Player X) automatically updates dependent systems (Match Data, Historical Stats)",
323+
"Agreed claims resolve directly to golden entity; conflicts require human review"
324+
]}
325+
>
323326

324327
```mermaid
325328
graph TD
@@ -345,18 +348,7 @@ graph TD
345348
class QUEUE,MT,GE,DEP1,DEP2 system
346349
```
347350

348-
<details class="mt-4">
349-
<summary class="cursor-pointer text-xs font-normal text-text-lighter hover:text-accent">Text description of relationships</summary>
350-
<ul class="mt-2 ml-4 text-xs font-light text-text-lighter space-y-1">
351-
<li><strong>Data sources (amber):</strong> Collector A, Scraped Source, and Third-party API submit nationality claims</li>
352-
<li><strong>Claims (blue):</strong> "Nationality: Egypt" and "Nationality: UK" represent conflicting data</li>
353-
<li><strong>Conflict resolution:</strong> Conflicting claim (Nationality: UK) routes to Pending Conflicts Queue</li>
354-
<li><strong>System nodes (emerald):</strong> Metadata Team Review resolves conflicts and updates Golden Entity</li>
355-
<li><strong>Cascade:</strong> Golden Entity (Player X) automatically updates dependent systems (Match Data, Historical Stats)</li>
356-
<li>Agreed claims resolve directly to golden entity; conflicts require human review</li>
357-
</ul>
358-
</details>
359-
</figure>
351+
</MermaidDiagram>
360352

361353
<Body size="sm" as="p" class="mb-6 text-neutral italic">
362354
Claims from data sources (amber) flow into the system. Conflicts (blue) route to a queue. The metadata

0 commit comments

Comments
 (0)