Files
HMDesign/packages/chart/src/components/line-points.tsx
T
shkim4 7ff852952a style(chart): open chart pages and surface/track tokens
- @hm-design/chart: add --hm-chart-track (radial tracks, horizontal bar
  hover band; warm neutral light) and --hm-chart-surface (slice / segment
  gaps, Dot rings, label halos, radial meter center; white by default)
- --hm-chart-bar-name-halo stays an optional override and falls back to
  --hm-chart-surface; official chart.tsx unchanged
- Storybook Chart pages (Bar / Line / Area / Donut / Pie / Radar / Radial):
  open layout without example cards, 2-4 responsive columns, one sample
  data notice per page, surface token set to the content background
- Same sample data, props, tooltip and interaction as the example cards;
  examples entry and consumer example unchanged
- README: surface / track token usage
2026-11-12 17:12:09 +09:00

255 lines
9.8 KiB
TypeScript

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-chart-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} />
}