Report: extract MembershipChart to components/report/
This commit is contained in:
+6
-383
@@ -17,9 +17,13 @@ import {
|
|||||||
formatShortDate,
|
formatShortDate,
|
||||||
computeDateRange,
|
computeDateRange,
|
||||||
Chevron,
|
Chevron,
|
||||||
numberFmt,
|
|
||||||
} from "./report/FieldHistory";
|
} from "./report/FieldHistory";
|
||||||
import { DateTimeline, generateAxisTicks } from "./report/DateTimeline";
|
import { DateTimeline } from "./report/DateTimeline";
|
||||||
|
import {
|
||||||
|
MembershipChart,
|
||||||
|
MEMBERS_ACTUAL_NAME,
|
||||||
|
MEMBERS_GOAL_NAME,
|
||||||
|
} from "./report/MembershipChart";
|
||||||
|
|
||||||
interface ReportViewProps {
|
interface ReportViewProps {
|
||||||
config: FormConfig;
|
config: FormConfig;
|
||||||
@@ -227,387 +231,6 @@ function StageProgress({ currentRank }: { currentRank: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEMBERS_ACTUAL_NAME = "Members__current_";
|
|
||||||
const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dedicated comparison chart for the org's actual member-owner count vs the
|
|
||||||
* goal it set for the current stage. Renders when at least one of the
|
|
||||||
* two fields has historical data. Both series are drawn as step lines
|
|
||||||
* (carry-forward semantics — a measurement holds until the next one
|
|
||||||
* updates it, then extends to the right edge). Header carries a
|
|
||||||
* current/goal summary with the gap; legend sits below the chart.
|
|
||||||
*/
|
|
||||||
function MembershipChart({
|
|
||||||
membersField,
|
|
||||||
goalField,
|
|
||||||
membersHistory,
|
|
||||||
goalHistory,
|
|
||||||
}: {
|
|
||||||
membersField?: FieldConfig;
|
|
||||||
goalField?: FieldConfig;
|
|
||||||
membersHistory?: FieldHistoryEntry[];
|
|
||||||
goalHistory?: FieldHistoryEntry[];
|
|
||||||
}) {
|
|
||||||
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
|
|
||||||
(entries ?? [])
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((e) => ({
|
|
||||||
t: new Date(e.date).getTime(),
|
|
||||||
v: typeof e.value === "number" ? e.value : Number(e.value),
|
|
||||||
}))
|
|
||||||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
|
|
||||||
|
|
||||||
const actualPoints = toPoints(membersHistory);
|
|
||||||
const goalPoints = toPoints(goalHistory);
|
|
||||||
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
|
|
||||||
|
|
||||||
// Combined axes
|
|
||||||
const all = [...actualPoints, ...goalPoints];
|
|
||||||
const times = all.map((p) => p.t);
|
|
||||||
const values = all.map((p) => p.v);
|
|
||||||
let minT = Math.min(...times);
|
|
||||||
let maxT = Math.max(...times);
|
|
||||||
if (minT === maxT) {
|
|
||||||
// Single-point chart — pad the axis ±15 days so the dot isn't on
|
|
||||||
// top of the y-axis line.
|
|
||||||
const pad = 15 * 24 * 3600 * 1000;
|
|
||||||
minT -= pad;
|
|
||||||
maxT += pad;
|
|
||||||
}
|
|
||||||
// Always include 0 in y so growth from a small starting count reads true.
|
|
||||||
const rawMin = Math.min(...values, 0);
|
|
||||||
const rawMax = Math.max(...values);
|
|
||||||
const yTicks = niceYTicks(rawMin, rawMax, 4);
|
|
||||||
const yMin = yTicks[0];
|
|
||||||
const yMax = yTicks[yTicks.length - 1];
|
|
||||||
const yRange = yMax - yMin || 1;
|
|
||||||
const tRange = maxT - minT || 1;
|
|
||||||
|
|
||||||
// Chart geometry (viewBox units).
|
|
||||||
const W = 580;
|
|
||||||
const H = 200;
|
|
||||||
const padL = 40;
|
|
||||||
const padR = 28;
|
|
||||||
const padT = 14;
|
|
||||||
const padB = 30;
|
|
||||||
const plotW = W - padL - padR;
|
|
||||||
const plotH = H - padT - padB;
|
|
||||||
|
|
||||||
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
|
|
||||||
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
|
|
||||||
|
|
||||||
// Both Actual and Goal use a carry-forward step line: between measurements
|
|
||||||
// the chart holds the prior value rather than interpolating diagonally,
|
|
||||||
// and the final value extends flat to the right edge of the chart. This
|
|
||||||
// way periods with no fresh measurement read as "unchanged since last
|
|
||||||
// reported" instead of suggesting a smooth dip or rise that we don't
|
|
||||||
// actually have evidence for.
|
|
||||||
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
|
||||||
let actualPath = "";
|
|
||||||
if (actualCoords.length === 1) {
|
|
||||||
const c = actualCoords[0];
|
|
||||||
actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`;
|
|
||||||
} else if (actualCoords.length > 1) {
|
|
||||||
const parts: string[] = [
|
|
||||||
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`,
|
|
||||||
];
|
|
||||||
for (let i = 1; i < actualCoords.length; i++) {
|
|
||||||
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`);
|
|
||||||
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
const last = actualCoords[actualCoords.length - 1];
|
|
||||||
parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`);
|
|
||||||
actualPath = parts.join(" ");
|
|
||||||
}
|
|
||||||
const actualArea = actualPath
|
|
||||||
? `${actualPath} L${(W - padR).toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// Step the goal: hold each value until the next change, then extend
|
|
||||||
// the final value to the right edge of the chart.
|
|
||||||
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
|
||||||
let goalPath = "";
|
|
||||||
if (goalCoords.length === 1) {
|
|
||||||
const c = goalCoords[0];
|
|
||||||
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
|
|
||||||
} else if (goalCoords.length > 1) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
|
|
||||||
for (let i = 1; i < goalCoords.length; i++) {
|
|
||||||
// step: horizontal to next x at previous y, then vertical to new y
|
|
||||||
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
|
|
||||||
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
// Extend the most-recent goal to the right edge as a flat line.
|
|
||||||
const lastG = goalCoords[goalCoords.length - 1];
|
|
||||||
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
|
|
||||||
goalPath = parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary stat: take most-recent of each series for the gap callout.
|
|
||||||
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
|
|
||||||
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
|
|
||||||
const gap =
|
|
||||||
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
|
|
||||||
const gapText =
|
|
||||||
gap === null
|
|
||||||
? null
|
|
||||||
: gap > 0
|
|
||||||
? `${numberFmt.format(gap)} to go`
|
|
||||||
: gap < 0
|
|
||||||
? `${numberFmt.format(-gap)} above goal`
|
|
||||||
: "at goal";
|
|
||||||
const gapTone =
|
|
||||||
gap === null
|
|
||||||
? ""
|
|
||||||
: gap > 0
|
|
||||||
? "text-clay-700"
|
|
||||||
: gap < 0
|
|
||||||
? "text-leaf-700"
|
|
||||||
: "text-leaf-700";
|
|
||||||
|
|
||||||
const xTicks = generateAxisTicks(minT, maxT);
|
|
||||||
const todayT = Date.now();
|
|
||||||
const todayInRange = todayT >= minT && todayT <= maxT;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-labelledby="membership-chart-heading"
|
|
||||||
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
|
|
||||||
<div>
|
|
||||||
<h2
|
|
||||||
id="membership-chart-heading"
|
|
||||||
className="font-display text-base font-medium leading-tight text-ink"
|
|
||||||
>
|
|
||||||
Membership — goal vs. actual
|
|
||||||
</h2>
|
|
||||||
<p className="mt-0.5 text-xs text-ink-mute">
|
|
||||||
Member-owner count tracked over time against the goal set for the org's
|
|
||||||
current stage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{latestActual !== null && (
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
|
||||||
<span className="font-medium text-leaf-800">
|
|
||||||
{numberFmt.format(latestActual)}
|
|
||||||
</span>
|
|
||||||
{latestGoal !== null && (
|
|
||||||
<>
|
|
||||||
{" of "}
|
|
||||||
<span className="font-medium text-clay-700">
|
|
||||||
{numberFmt.format(latestGoal)}
|
|
||||||
</span>
|
|
||||||
{gapText && (
|
|
||||||
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
|
|
||||||
· {gapText}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
role="img"
|
|
||||||
aria-label="Membership goal versus actual over time"
|
|
||||||
className="mt-4 block h-48 w-full sm:h-52"
|
|
||||||
>
|
|
||||||
{/* Y-axis gridlines + value labels */}
|
|
||||||
{yTicks.map((v, i) => {
|
|
||||||
const y = yOf(v);
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
<line
|
|
||||||
x1={padL}
|
|
||||||
x2={W - padR}
|
|
||||||
y1={y}
|
|
||||||
y2={y}
|
|
||||||
className="stroke-rule-soft"
|
|
||||||
strokeWidth="0.5"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={padL - 6}
|
|
||||||
y={y}
|
|
||||||
textAnchor="end"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
className="fill-ink-mute text-[9px] tabular-nums"
|
|
||||||
>
|
|
||||||
{numberFmt.format(v)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Today guide */}
|
|
||||||
{todayInRange && (
|
|
||||||
<line
|
|
||||||
x1={xOf(todayT)}
|
|
||||||
x2={xOf(todayT)}
|
|
||||||
y1={padT}
|
|
||||||
y2={padT + plotH}
|
|
||||||
className="stroke-clay-300"
|
|
||||||
strokeWidth="0.75"
|
|
||||||
strokeDasharray="2 3"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Goal step line */}
|
|
||||||
{goalPath && (
|
|
||||||
<path
|
|
||||||
d={goalPath}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-clay-600"
|
|
||||||
strokeWidth="1.4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="miter"
|
|
||||||
strokeDasharray="5 3"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{goalCoords.map((c, i) => (
|
|
||||||
<circle
|
|
||||||
key={`g-${i}`}
|
|
||||||
cx={c.x}
|
|
||||||
cy={c.y}
|
|
||||||
r="2.5"
|
|
||||||
className="fill-clay-600"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Actual line with faint area fill */}
|
|
||||||
{actualArea && (
|
|
||||||
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
|
|
||||||
)}
|
|
||||||
{actualPath && (
|
|
||||||
<path
|
|
||||||
d={actualPath}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-leaf-700"
|
|
||||||
strokeWidth="1.75"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{actualCoords.map((c, i) => (
|
|
||||||
<circle
|
|
||||||
key={`a-${i}`}
|
|
||||||
cx={c.x}
|
|
||||||
cy={c.y}
|
|
||||||
r={i === actualCoords.length - 1 ? 3.5 : 2}
|
|
||||||
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Most-recent point value labels */}
|
|
||||||
{actualCoords.length > 0 && (
|
|
||||||
<text
|
|
||||||
x={actualCoords[actualCoords.length - 1].x + 6}
|
|
||||||
y={actualCoords[actualCoords.length - 1].y - 6}
|
|
||||||
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
|
|
||||||
>
|
|
||||||
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
{goalCoords.length > 0 && (
|
|
||||||
<text
|
|
||||||
x={xOf(maxT) - 4}
|
|
||||||
y={goalCoords[goalCoords.length - 1].y - 6}
|
|
||||||
textAnchor="end"
|
|
||||||
className="fill-clay-700 text-[10px] font-medium tabular-nums"
|
|
||||||
>
|
|
||||||
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* X-axis baseline + ticks */}
|
|
||||||
<line
|
|
||||||
x1={padL}
|
|
||||||
x2={W - padR}
|
|
||||||
y1={padT + plotH}
|
|
||||||
y2={padT + plotH}
|
|
||||||
className="stroke-rule"
|
|
||||||
strokeWidth="0.75"
|
|
||||||
/>
|
|
||||||
{xTicks.map((tk, i) => {
|
|
||||||
const x = xOf(tk.t);
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
<line
|
|
||||||
x1={x}
|
|
||||||
x2={x}
|
|
||||||
y1={padT + plotH}
|
|
||||||
y2={padT + plotH + 3}
|
|
||||||
className="stroke-rule"
|
|
||||||
strokeWidth="0.75"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={x}
|
|
||||||
y={padT + plotH + 14}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
|
|
||||||
>
|
|
||||||
{tk.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
|
|
||||||
<li className="inline-flex items-center gap-2">
|
|
||||||
<span aria-hidden className="block h-px w-6 bg-leaf-700">
|
|
||||||
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Actual{membersField?.label && membersField.label !== "Member-Owners (current)" ? ` (${membersField.label})` : ""}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="inline-flex items-center gap-2">
|
|
||||||
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
|
|
||||||
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Goal{goalField?.label && goalField.label !== "Member-Owner Goal for current Stage" ? ` (${goalField.label})` : ""}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Choose 3–5 round-number tick values that span [min, max]. Step is snapped
|
|
||||||
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
|
|
||||||
*/
|
|
||||||
function niceYTicks(min: number, max: number, target = 4): number[] {
|
|
||||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
|
|
||||||
if (min === max) return [min - 1, min, min + 1];
|
|
||||||
const range = max - min;
|
|
||||||
const rawStep = range / target;
|
|
||||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
||||||
const normalized = rawStep / magnitude;
|
|
||||||
let step: number;
|
|
||||||
if (normalized < 1.5) step = 1 * magnitude;
|
|
||||||
else if (normalized < 3) step = 2 * magnitude;
|
|
||||||
else if (normalized < 4) step = 2.5 * magnitude;
|
|
||||||
else if (normalized < 7) step = 5 * magnitude;
|
|
||||||
else step = 10 * magnitude;
|
|
||||||
const niceMin = Math.floor(min / step) * step;
|
|
||||||
const niceMax = Math.ceil(max / step) * step;
|
|
||||||
const ticks: number[] = [];
|
|
||||||
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
|
|
||||||
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
|
|
||||||
}
|
|
||||||
return ticks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportSection({
|
function ReportSection({
|
||||||
section,
|
section,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FieldConfig, FieldHistoryEntry } from "@/types/form";
|
||||||
|
import { numberFmt } from "./FieldHistory";
|
||||||
|
import { generateAxisTicks } from "./DateTimeline";
|
||||||
|
|
||||||
|
export const MEMBERS_ACTUAL_NAME = "Members__current_";
|
||||||
|
export const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated comparison chart for the org's actual member-owner count vs the
|
||||||
|
* goal it set for the current stage. Renders when at least one of the
|
||||||
|
* two fields has historical data. Both series are drawn as step lines
|
||||||
|
* (carry-forward semantics — a measurement holds until the next one
|
||||||
|
* updates it, then extends to the right edge). Header carries a
|
||||||
|
* current/goal summary with the gap; legend sits below the chart.
|
||||||
|
*/
|
||||||
|
export function MembershipChart({
|
||||||
|
membersField,
|
||||||
|
goalField,
|
||||||
|
membersHistory,
|
||||||
|
goalHistory,
|
||||||
|
}: {
|
||||||
|
membersField?: FieldConfig;
|
||||||
|
goalField?: FieldConfig;
|
||||||
|
membersHistory?: FieldHistoryEntry[];
|
||||||
|
goalHistory?: FieldHistoryEntry[];
|
||||||
|
}) {
|
||||||
|
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
|
||||||
|
(entries ?? [])
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((e) => ({
|
||||||
|
t: new Date(e.date).getTime(),
|
||||||
|
v: typeof e.value === "number" ? e.value : Number(e.value),
|
||||||
|
}))
|
||||||
|
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
|
||||||
|
|
||||||
|
const actualPoints = toPoints(membersHistory);
|
||||||
|
const goalPoints = toPoints(goalHistory);
|
||||||
|
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
|
||||||
|
|
||||||
|
// Combined axes
|
||||||
|
const all = [...actualPoints, ...goalPoints];
|
||||||
|
const times = all.map((p) => p.t);
|
||||||
|
const values = all.map((p) => p.v);
|
||||||
|
let minT = Math.min(...times);
|
||||||
|
let maxT = Math.max(...times);
|
||||||
|
if (minT === maxT) {
|
||||||
|
// Single-point chart — pad the axis ±15 days so the dot isn't on
|
||||||
|
// top of the y-axis line.
|
||||||
|
const pad = 15 * 24 * 3600 * 1000;
|
||||||
|
minT -= pad;
|
||||||
|
maxT += pad;
|
||||||
|
}
|
||||||
|
// Always include 0 in y so growth from a small starting count reads true.
|
||||||
|
const rawMin = Math.min(...values, 0);
|
||||||
|
const rawMax = Math.max(...values);
|
||||||
|
const yTicks = niceYTicks(rawMin, rawMax, 4);
|
||||||
|
const yMin = yTicks[0];
|
||||||
|
const yMax = yTicks[yTicks.length - 1];
|
||||||
|
const yRange = yMax - yMin || 1;
|
||||||
|
const tRange = maxT - minT || 1;
|
||||||
|
|
||||||
|
// Chart geometry (viewBox units).
|
||||||
|
const W = 580;
|
||||||
|
const H = 200;
|
||||||
|
const padL = 40;
|
||||||
|
const padR = 28;
|
||||||
|
const padT = 14;
|
||||||
|
const padB = 30;
|
||||||
|
const plotW = W - padL - padR;
|
||||||
|
const plotH = H - padT - padB;
|
||||||
|
|
||||||
|
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
|
||||||
|
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
|
||||||
|
|
||||||
|
// Both Actual and Goal use a carry-forward step line: between measurements
|
||||||
|
// the chart holds the prior value rather than interpolating diagonally,
|
||||||
|
// and the final value extends flat to the right edge of the chart. This
|
||||||
|
// way periods with no fresh measurement read as "unchanged since last
|
||||||
|
// reported" instead of suggesting a smooth dip or rise that we don't
|
||||||
|
// actually have evidence for.
|
||||||
|
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
||||||
|
let actualPath = "";
|
||||||
|
if (actualCoords.length === 1) {
|
||||||
|
const c = actualCoords[0];
|
||||||
|
actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`;
|
||||||
|
} else if (actualCoords.length > 1) {
|
||||||
|
const parts: string[] = [
|
||||||
|
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`,
|
||||||
|
];
|
||||||
|
for (let i = 1; i < actualCoords.length; i++) {
|
||||||
|
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`);
|
||||||
|
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
const last = actualCoords[actualCoords.length - 1];
|
||||||
|
parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`);
|
||||||
|
actualPath = parts.join(" ");
|
||||||
|
}
|
||||||
|
const actualArea = actualPath
|
||||||
|
? `${actualPath} L${(W - padR).toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Step the goal: hold each value until the next change, then extend
|
||||||
|
// the final value to the right edge of the chart.
|
||||||
|
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
||||||
|
let goalPath = "";
|
||||||
|
if (goalCoords.length === 1) {
|
||||||
|
const c = goalCoords[0];
|
||||||
|
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
|
||||||
|
} else if (goalCoords.length > 1) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
|
||||||
|
for (let i = 1; i < goalCoords.length; i++) {
|
||||||
|
// step: horizontal to next x at previous y, then vertical to new y
|
||||||
|
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
|
||||||
|
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
// Extend the most-recent goal to the right edge as a flat line.
|
||||||
|
const lastG = goalCoords[goalCoords.length - 1];
|
||||||
|
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
|
||||||
|
goalPath = parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary stat: take most-recent of each series for the gap callout.
|
||||||
|
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
|
||||||
|
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
|
||||||
|
const gap =
|
||||||
|
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
|
||||||
|
const gapText =
|
||||||
|
gap === null
|
||||||
|
? null
|
||||||
|
: gap > 0
|
||||||
|
? `${numberFmt.format(gap)} to go`
|
||||||
|
: gap < 0
|
||||||
|
? `${numberFmt.format(-gap)} above goal`
|
||||||
|
: "at goal";
|
||||||
|
const gapTone =
|
||||||
|
gap === null
|
||||||
|
? ""
|
||||||
|
: gap > 0
|
||||||
|
? "text-clay-700"
|
||||||
|
: gap < 0
|
||||||
|
? "text-leaf-700"
|
||||||
|
: "text-leaf-700";
|
||||||
|
|
||||||
|
const xTicks = generateAxisTicks(minT, maxT);
|
||||||
|
const todayT = Date.now();
|
||||||
|
const todayInRange = todayT >= minT && todayT <= maxT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="membership-chart-heading"
|
||||||
|
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="membership-chart-heading"
|
||||||
|
className="font-display text-base font-medium leading-tight text-ink"
|
||||||
|
>
|
||||||
|
Membership — goal vs. actual
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-mute">
|
||||||
|
Member-owner count tracked over time against the goal set for the org's
|
||||||
|
current stage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{latestActual !== null && (
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
|
<span className="font-medium text-leaf-800">
|
||||||
|
{numberFmt.format(latestActual)}
|
||||||
|
</span>
|
||||||
|
{latestGoal !== null && (
|
||||||
|
<>
|
||||||
|
{" of "}
|
||||||
|
<span className="font-medium text-clay-700">
|
||||||
|
{numberFmt.format(latestGoal)}
|
||||||
|
</span>
|
||||||
|
{gapText && (
|
||||||
|
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
|
||||||
|
· {gapText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="Membership goal versus actual over time"
|
||||||
|
className="mt-4 block h-48 w-full sm:h-52"
|
||||||
|
>
|
||||||
|
{/* Y-axis gridlines + value labels */}
|
||||||
|
{yTicks.map((v, i) => {
|
||||||
|
const y = yOf(v);
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
x2={W - padR}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
className="stroke-rule-soft"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padL - 6}
|
||||||
|
y={y}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-ink-mute text-[9px] tabular-nums"
|
||||||
|
>
|
||||||
|
{numberFmt.format(v)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Today guide */}
|
||||||
|
{todayInRange && (
|
||||||
|
<line
|
||||||
|
x1={xOf(todayT)}
|
||||||
|
x2={xOf(todayT)}
|
||||||
|
y1={padT}
|
||||||
|
y2={padT + plotH}
|
||||||
|
className="stroke-clay-300"
|
||||||
|
strokeWidth="0.75"
|
||||||
|
strokeDasharray="2 3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Goal step line */}
|
||||||
|
{goalPath && (
|
||||||
|
<path
|
||||||
|
d={goalPath}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-clay-600"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{goalCoords.map((c, i) => (
|
||||||
|
<circle
|
||||||
|
key={`g-${i}`}
|
||||||
|
cx={c.x}
|
||||||
|
cy={c.y}
|
||||||
|
r="2.5"
|
||||||
|
className="fill-clay-600"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Actual line with faint area fill */}
|
||||||
|
{actualArea && (
|
||||||
|
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
|
||||||
|
)}
|
||||||
|
{actualPath && (
|
||||||
|
<path
|
||||||
|
d={actualPath}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-leaf-700"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{actualCoords.map((c, i) => (
|
||||||
|
<circle
|
||||||
|
key={`a-${i}`}
|
||||||
|
cx={c.x}
|
||||||
|
cy={c.y}
|
||||||
|
r={i === actualCoords.length - 1 ? 3.5 : 2}
|
||||||
|
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Most-recent point value labels */}
|
||||||
|
{actualCoords.length > 0 && (
|
||||||
|
<text
|
||||||
|
x={actualCoords[actualCoords.length - 1].x + 6}
|
||||||
|
y={actualCoords[actualCoords.length - 1].y - 6}
|
||||||
|
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
|
||||||
|
>
|
||||||
|
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{goalCoords.length > 0 && (
|
||||||
|
<text
|
||||||
|
x={xOf(maxT) - 4}
|
||||||
|
y={goalCoords[goalCoords.length - 1].y - 6}
|
||||||
|
textAnchor="end"
|
||||||
|
className="fill-clay-700 text-[10px] font-medium tabular-nums"
|
||||||
|
>
|
||||||
|
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X-axis baseline + ticks */}
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
x2={W - padR}
|
||||||
|
y1={padT + plotH}
|
||||||
|
y2={padT + plotH}
|
||||||
|
className="stroke-rule"
|
||||||
|
strokeWidth="0.75"
|
||||||
|
/>
|
||||||
|
{xTicks.map((tk, i) => {
|
||||||
|
const x = xOf(tk.t);
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<line
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={padT + plotH}
|
||||||
|
y2={padT + plotH + 3}
|
||||||
|
className="stroke-rule"
|
||||||
|
strokeWidth="0.75"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={padT + plotH + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
|
||||||
|
>
|
||||||
|
{tk.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
|
||||||
|
<li className="inline-flex items-center gap-2">
|
||||||
|
<span aria-hidden className="block h-px w-6 bg-leaf-700">
|
||||||
|
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Actual{membersField?.label && membersField.label !== "Member-Owners (current)" ? ` (${membersField.label})` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="inline-flex items-center gap-2">
|
||||||
|
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
|
||||||
|
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Goal{goalField?.label && goalField.label !== "Member-Owner Goal for current Stage" ? ` (${goalField.label})` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose 3–5 round-number tick values that span [min, max]. Step is snapped
|
||||||
|
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
|
||||||
|
*/
|
||||||
|
function niceYTicks(min: number, max: number, target = 4): number[] {
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
|
||||||
|
if (min === max) return [min - 1, min, min + 1];
|
||||||
|
const range = max - min;
|
||||||
|
const rawStep = range / target;
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||||
|
const normalized = rawStep / magnitude;
|
||||||
|
let step: number;
|
||||||
|
if (normalized < 1.5) step = 1 * magnitude;
|
||||||
|
else if (normalized < 3) step = 2 * magnitude;
|
||||||
|
else if (normalized < 4) step = 2.5 * magnitude;
|
||||||
|
else if (normalized < 7) step = 5 * magnitude;
|
||||||
|
else step = 10 * magnitude;
|
||||||
|
const niceMin = Math.floor(min / step) * step;
|
||||||
|
const niceMax = Math.ceil(max / step) * step;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
|
||||||
|
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user