feat(compare): DotStrip with England-average anchor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
.row {
|
||||
margin: 1.1rem 0 1.6rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.headNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.strip {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 15px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.anchorTick {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 2px;
|
||||
height: 26px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.anchorLabel {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pointLabelAbove {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.row {
|
||||
margin: 0.9rem 0 1.3rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.strip {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.point {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pointLabel {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* DotStrip — the compare screen's signature element: one measure per strip,
|
||||
* every school's dot on a shared track, anchored by a grey England-average
|
||||
* tick so "right of the tick = above average" needs no domain knowledge.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { stripPositions } from '@/lib/compareLogic';
|
||||
import { CHART_COLORS, CHART_TEXT_COLORS } from '@/lib/utils';
|
||||
import styles from './DotStrip.module.css';
|
||||
|
||||
export interface DotStripProps {
|
||||
label: string;
|
||||
/** One value per school; index = the school's chart-colour index. */
|
||||
values: Array<number | null>;
|
||||
schoolNames: string[];
|
||||
/** Anchor tick, e.g. { value: 62, label: 'England 62%' }. Omit when the
|
||||
* benchmark isn't available — the caller should say why in `headNote`. */
|
||||
anchor?: { value: number; label: string } | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
/** Tooltip on the measure label (plain-English definition). */
|
||||
tip?: string;
|
||||
/** Small note on the right of the header row (e.g. the tick legend). */
|
||||
headNote?: string;
|
||||
}
|
||||
|
||||
export function DotStrip({
|
||||
label,
|
||||
values,
|
||||
schoolNames,
|
||||
anchor = null,
|
||||
min = 0,
|
||||
max = 100,
|
||||
unit = '%',
|
||||
tip,
|
||||
headNote,
|
||||
}: DotStripProps) {
|
||||
const points = stripPositions(values, min, max);
|
||||
const span = max - min;
|
||||
const anchorPos =
|
||||
anchor != null
|
||||
? Math.min(100, Math.max(0, ((anchor.value - min) / span) * 100))
|
||||
: null;
|
||||
|
||||
const ariaParts = [
|
||||
anchor ? `${anchor.label}` : null,
|
||||
...points.map(
|
||||
(p) => `${schoolNames[p.schoolIndex] ?? `School ${p.schoolIndex + 1}`} ${p.value}${unit}`,
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title} title={tip}>
|
||||
{label}
|
||||
</span>
|
||||
{headNote && <span className={styles.headNote}>{headNote}</span>}
|
||||
</div>
|
||||
<div className={styles.strip} role="img" aria-label={`${label}: ${ariaParts.join(', ')}`}>
|
||||
<div className={styles.track} />
|
||||
{anchorPos != null && anchor && (
|
||||
<>
|
||||
<span className={styles.anchorTick} style={{ left: `${anchorPos}%` }} />
|
||||
<span className={styles.anchorLabel} style={{ left: `${anchorPos}%` }}>
|
||||
{anchor.label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{points.map((p) => (
|
||||
<span key={p.schoolIndex}>
|
||||
<span
|
||||
className={styles.point}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
background: CHART_COLORS[p.schoolIndex % CHART_COLORS.length],
|
||||
}}
|
||||
title={`${schoolNames[p.schoolIndex] ?? ''}: ${p.value}${unit}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.pointLabel} ${p.labelAbove ? styles.pointLabelAbove : ''}`}
|
||||
style={{
|
||||
left: `${p.pos}%`,
|
||||
color: CHART_TEXT_COLORS[p.schoolIndex % CHART_TEXT_COLORS.length],
|
||||
}}
|
||||
>
|
||||
{p.value}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user