feat(chart): add @hm-design/chart package with Bar, Line, Donut, Pie, Radar, Area and Radial charts
- new workspace package packages/chart (shadcn/ui Chart + Recharts, HM Theme tokens), examples entry with sample data - Storybook Chart group (src/chart), consumer example (examples/chart), build:chart / build:chart-example scripts - README rewritten as the AI usage guide (install, CSS wiring, chart selection, data rules, verification) - docs: package count/list, Submodule guide (chart workspace, React dedupe), shared AI_GUIDE row, MAINTAINING, story inventory
This commit is contained in:
1 parent
228944a06b
commit
dc9258599c
56 files changed
+3979
-28
No files matched your search
@@ -0,0 +1,254 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { usePlotArea, useYAxisScale } from "recharts"
|
||||
import { ChartTooltipContent } from "./chart"
|
||||
|
||||
// Custom Dot, value label and tooltip for HMLineChart. They are rendered inside the chart, so they can read the plot area and scales.
|
||||
|
||||
/** Space kept between the plot edge and the first / last point so their Dot and label are not clipped. */
|
||||
export const POINT_AXIS_PADDING = 22
|
||||
const DOT_RADIUS = 5
|
||||
const LABEL_GAP = 7 // between the Dot edge and the label
|
||||
const FAR_LABEL_GAP = 18 // same, for the second try when the near spot touches a line
|
||||
const CHAR_WIDTH = 7.2 // estimated 12px digit width
|
||||
const LABEL_ASCENT = 10
|
||||
const LABEL_DESCENT = 2
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
type Curve = "monotone" | "linear" | "step"
|
||||
type Point = { x: number; y: number }
|
||||
type Box = { left: number; right: number; top: number; bottom: number }
|
||||
type Placement = { x: number; y: number; anchor: "start" | "middle" | "end"; box: Box }
|
||||
|
||||
function pointColor(row: Row | undefined, colorKey: string | undefined, fallback: string) {
|
||||
const own = colorKey ? row?.[colorKey] : undefined
|
||||
return typeof own === "string" && own ? own : fallback
|
||||
}
|
||||
|
||||
/** Dot with a surface-colored ring: the series color, or the row's own color when `colorKey` is set. */
|
||||
export function PointDot({
|
||||
cx,
|
||||
cy,
|
||||
payload,
|
||||
colorKey,
|
||||
fallback,
|
||||
radius = DOT_RADIUS,
|
||||
}: {
|
||||
cx?: number | string
|
||||
cy?: number | string
|
||||
payload?: Row
|
||||
colorKey?: string
|
||||
fallback: string
|
||||
radius?: number
|
||||
}) {
|
||||
const x = Number(cx)
|
||||
const y = Number(cy)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
||||
return (
|
||||
<circle
|
||||
data-slot="chart-point-dot"
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radius}
|
||||
fill={pointColor(payload, colorKey, fallback)}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function inflate(box: Box, by: number): Box {
|
||||
return { left: box.left - by, right: box.right + by, top: box.top - by, bottom: box.bottom + by }
|
||||
}
|
||||
const inside = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
|
||||
|
||||
/** Number of sample points (about every 2px) of the segment that fall inside the box. */
|
||||
function segmentHits(box: Box, a: Point, b: Point) {
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2))
|
||||
let hits = 0
|
||||
for (let i = 0; i <= steps; i += 1) {
|
||||
const t = i / steps
|
||||
if (inside(box, a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) hits += 1
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function circleTouchesBox(box: Box, c: Point, radius: number) {
|
||||
const dx = Math.max(box.left - c.x, 0, c.x - box.right)
|
||||
const dy = Math.max(box.top - c.y, 0, c.y - box.bottom)
|
||||
return Math.hypot(dx, dy) < radius
|
||||
}
|
||||
|
||||
const boxesOverlap = (a: Box, b: Box) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)
|
||||
|
||||
/** Line segments a series draws between two neighbouring points (a curve is approximated by its chord). */
|
||||
function connection(a: Point, b: Point, curve: Curve): Array<[Point, Point]> {
|
||||
if (curve !== "step") return [[a, b]]
|
||||
const mid = (a.x + b.x) / 2
|
||||
return [
|
||||
[a, { x: mid, y: a.y }],
|
||||
[{ x: mid, y: a.y }, { x: mid, y: b.y }],
|
||||
[{ x: mid, y: b.y }, b],
|
||||
]
|
||||
}
|
||||
|
||||
function candidateBoxes(p: Point, width: number, below: boolean): Placement[] {
|
||||
const above = (gap: number): Placement => {
|
||||
const baseline = p.y - DOT_RADIUS - gap
|
||||
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top: baseline - LABEL_ASCENT, bottom: baseline + LABEL_DESCENT } }
|
||||
}
|
||||
const under = (gap: number): Placement => {
|
||||
const top = p.y + DOT_RADIUS + gap
|
||||
const baseline = top + LABEL_ASCENT
|
||||
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top, bottom: baseline + LABEL_DESCENT } }
|
||||
}
|
||||
const side = (dir: 1 | -1): Placement => {
|
||||
const x = p.x + dir * (DOT_RADIUS + 4)
|
||||
return {
|
||||
x,
|
||||
y: p.y + 4,
|
||||
anchor: dir === 1 ? "start" : "end",
|
||||
box: { left: dir === 1 ? x : x - width, right: dir === 1 ? x + width : x, top: p.y - 6, bottom: p.y + 6 },
|
||||
}
|
||||
}
|
||||
const [first, second] = below ? [under, above] : [above, under]
|
||||
return [first(LABEL_GAP), first(FAR_LABEL_GAP), second(LABEL_GAP), second(FAR_LABEL_GAP), side(1), side(-1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Value label of a Dot, in the Dot's color.
|
||||
* - Default side: one series is labelled above its Dot. With several series the higher value at a category goes above and the lower
|
||||
* below (a tie keeps the series order).
|
||||
* - The label then takes the first free spot of: default side near, default side farther, the other side near and farther, right,
|
||||
* left. A spot is free when it touches no line (curve, straight or step), no Dot, no earlier series' label and is neither above
|
||||
* the plot top nor on the axis labels. With no free spot the label is omitted; the Dot and the tooltip remain.
|
||||
* - When points are closer than a label is wide (narrow screens), only every n-th label is drawn; the tooltip shows every value.
|
||||
*/
|
||||
export function PointValueLabel({
|
||||
x,
|
||||
y,
|
||||
index,
|
||||
data,
|
||||
seriesKeys,
|
||||
seriesIndex,
|
||||
curve,
|
||||
colorKey,
|
||||
fallback,
|
||||
}: {
|
||||
x?: number | string
|
||||
y?: number | string
|
||||
index?: number
|
||||
data: ReadonlyArray<Row>
|
||||
seriesKeys: readonly string[]
|
||||
seriesIndex: number
|
||||
curve: Curve
|
||||
colorKey?: string
|
||||
fallback: string
|
||||
}) {
|
||||
const plot = usePlotArea()
|
||||
const yScale = useYAxisScale()
|
||||
const px = Number(x)
|
||||
const py = Number(y)
|
||||
const at = index ?? 0
|
||||
const row = data[at]
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !row) return null
|
||||
|
||||
const textOf = (key: string) => Number(row[key]).toLocaleString()
|
||||
const spacing = plot && data.length > 1 ? (plot.width - 2 * POINT_AXIS_PADDING) / (data.length - 1) : Infinity
|
||||
const widest = Math.max(...seriesKeys.flatMap(key => data.map(item => Number(item[key]).toLocaleString().length)))
|
||||
const step = Math.max(1, Math.ceil((widest * CHAR_WIDTH + 6) / spacing))
|
||||
if (at % step !== 0) return null
|
||||
|
||||
// Pixel position of any series' point at any category, from this point and the evenly spaced category axis.
|
||||
const scaleOk = typeof yScale === "function" && Number.isFinite(Number(yScale(Number(row[seriesKeys[seriesIndex]])))) &&
|
||||
Math.abs(Number(yScale(Number(row[seriesKeys[seriesIndex]]))) - py) < 1.5
|
||||
const pointOf = (seriesNo: number, i: number): Point | undefined => {
|
||||
if (!scaleOk || !Number.isFinite(spacing)) return undefined
|
||||
const value = Number(data[i]?.[seriesKeys[seriesNo]])
|
||||
const py2 = Number(yScale?.(value))
|
||||
return Number.isFinite(value) && Number.isFinite(py2) ? { x: px + (i - at) * spacing, y: py2 } : undefined
|
||||
}
|
||||
|
||||
const first = Math.max(0, at - 2)
|
||||
const last = Math.min(data.length - 1, at + 2)
|
||||
const bottomLimit = plot ? plot.y + plot.height + 4 : Infinity
|
||||
const topLimit = 2
|
||||
|
||||
const cost = (candidate: Placement, taken: Box[]) => {
|
||||
const box = inflate(candidate.box, 1.5)
|
||||
let total = 0
|
||||
if (candidate.box.bottom > bottomLimit || candidate.box.top < topLimit) total += 200
|
||||
for (const other of taken) if (boxesOverlap(candidate.box, other)) total += 30
|
||||
for (let s = 0; s < seriesKeys.length; s += 1) {
|
||||
for (let i = first; i <= last; i += 1) {
|
||||
const here = pointOf(s, i)
|
||||
if (!here) continue
|
||||
if (circleTouchesBox(box, here, DOT_RADIUS + 1)) total += 8
|
||||
const next = i < last ? pointOf(s, i + 1) : undefined
|
||||
if (next) for (const [a, b] of connection(here, next, curve)) total += segmentHits(box, a, b)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const place = (seriesNo: number): Placement | null => {
|
||||
const own = seriesNo === seriesIndex ? { x: px, y: py } : pointOf(seriesNo, at)
|
||||
if (!own) return null
|
||||
const taken: Box[] = []
|
||||
for (let earlier = 0; earlier < seriesNo; earlier += 1) {
|
||||
const placed = place(earlier)
|
||||
if (placed) taken.push(placed.box)
|
||||
}
|
||||
const mine = Number(row[seriesKeys[seriesNo]])
|
||||
const below = seriesKeys.some((key, i) => i !== seriesNo && (Number(row[key]) > mine || (Number(row[key]) === mine && i < seriesNo)))
|
||||
const width = textOf(seriesKeys[seriesNo]).length * CHAR_WIDTH + 2
|
||||
let best: Placement | null = null
|
||||
let bestCost = Infinity
|
||||
for (const candidate of candidateBoxes(own, width, below)) {
|
||||
const c = cost(candidate, taken)
|
||||
if (c < bestCost) {
|
||||
best = candidate
|
||||
bestCost = c
|
||||
}
|
||||
if (c === 0) break
|
||||
}
|
||||
// No free spot: omit this label (its Dot and the tooltip stay) rather than touch a line, a Dot or another label.
|
||||
return bestCost === 0 ? best : null
|
||||
}
|
||||
|
||||
// Without a usable scale (it never happens inside HMLineChart) fall back to "first series above, others below".
|
||||
const fallbackPlacement = (): Placement => {
|
||||
const below = seriesIndex > 0
|
||||
return candidateBoxes({ x: px, y: py }, textOf(seriesKeys[seriesIndex]).length * CHAR_WIDTH + 2, below)[0]
|
||||
}
|
||||
const placement = scaleOk ? place(seriesIndex) : fallbackPlacement()
|
||||
if (!placement) return null
|
||||
|
||||
return (
|
||||
<text
|
||||
data-slot="chart-point-label"
|
||||
x={placement.x}
|
||||
y={placement.y}
|
||||
textAnchor={placement.anchor}
|
||||
fill={pointColor(row, colorKey, fallback)}
|
||||
>
|
||||
{textOf(seriesKeys[seriesIndex])}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip whose row indicators use each series' per-point color (`colorKeys`: series key -> row field). The legend and the
|
||||
* line keep the series color.
|
||||
*/
|
||||
export function PointColorTooltip({
|
||||
colorKeys,
|
||||
payload,
|
||||
...props
|
||||
}: ComponentProps<typeof ChartTooltipContent> & { colorKeys: Readonly<Record<string, string>> }) {
|
||||
const colored = payload?.map(item => {
|
||||
const field = colorKeys[String(item.dataKey)]
|
||||
const color = field ? (item.payload as Row | undefined)?.[field] : undefined
|
||||
return typeof color === "string" && color ? { ...item, payload: { ...(item.payload as Row), fill: color } } : item
|
||||
})
|
||||
return <ChartTooltipContent {...props} payload={colored} />
|
||||
}
|
||||
Reference in new issue
Block a user