Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website
This commit is contained in:
@@ -41,25 +41,32 @@ export interface ChartProps {
|
||||
|
||||
// bar / area: stack the series instead of grouping them side by side.
|
||||
stacked?: boolean;
|
||||
// bar only: lay the bars horizontally — categories run down the y-axis, values along x.
|
||||
horizontal?: boolean;
|
||||
// line / area: "smooth" draws a Catmull-Rom spline through the points.
|
||||
curve?: "linear" | "smooth";
|
||||
// donut only: inner-radius fraction of the outer radius (0.6 by default).
|
||||
donutRatio?: number;
|
||||
|
||||
// Give the chart depth: bars extrude, pie/donut tilt and gain a rim (line/area ignore
|
||||
// it). An embellishment — flat reads more precisely — but sometimes wanted.
|
||||
threeD?: boolean;
|
||||
depth?: number; // 3D extrusion depth in px (default 16).
|
||||
|
||||
height?: number; // px of the plot area (default 300). The legend adds its own height.
|
||||
width?: number; // fix the width instead of measuring the container.
|
||||
class?: string;
|
||||
|
||||
// Override the whole categorical palette (else the --color-chart-1..8 tokens).
|
||||
palette?: string[];
|
||||
// Format a value for the y-axis ticks and the tooltip. Defaults to en-US grouping.
|
||||
// Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping.
|
||||
valueFormat?: (v: number) => string;
|
||||
|
||||
legend?: boolean; // default: true when there is more than one series (or a pie).
|
||||
grid?: boolean; // cartesian only; default true.
|
||||
axes?: boolean; // cartesian only; default true.
|
||||
tooltip?: boolean; // default true.
|
||||
yMin?: number; // pin the y domain instead of deriving it from the data.
|
||||
yMin?: number; // pin the value domain instead of deriving it from the data.
|
||||
yMax?: number;
|
||||
}
|
||||
|
||||
@@ -77,6 +84,7 @@ const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is de
|
||||
const BAR_RADIUS = 4; // rounded data-end
|
||||
const SEG_GAP = 2; // the surface gap between touching marks (stacked segments)
|
||||
const MARK_R = 4; // hover marker radius (8px mark)
|
||||
const DEFAULT_DEPTH = 16; // 3D extrusion depth
|
||||
|
||||
// ── number + geometry helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -122,17 +130,29 @@ function niceScale(min: number, max: number, maxTicks = 5): { min: number; max:
|
||||
return { min: niceMin, max: niceMax, ticks };
|
||||
}
|
||||
|
||||
// A column with the two corners at its VALUE end rounded and the baseline end square —
|
||||
// the mark spec. Handles growing up or down from the baseline.
|
||||
function columnPath(x: number, w: number, yBase: number, yVal: number, r: number): string {
|
||||
const h = Math.abs(yBase - yVal);
|
||||
const rr = Math.max(0, Math.min(r, w / 2, h));
|
||||
if (yVal <= yBase) {
|
||||
const t = yVal;
|
||||
return `M${x},${yBase} L${x},${t + rr} Q${x},${t} ${x + rr},${t} L${x + w - rr},${t} Q${x + w},${t} ${x + w},${t + rr} L${x + w},${yBase} Z`;
|
||||
}
|
||||
const b = yVal;
|
||||
return `M${x},${yBase} L${x},${b - rr} Q${x},${b} ${x + rr},${b} L${x + w - rr},${b} Q${x + w},${b} ${x + w},${b - rr} L${x + w},${yBase} Z`;
|
||||
// A rectangle with a chosen subset of corners rounded — the data-end of a bar rounds,
|
||||
// the baseline end stays square, and which end that is depends on orientation and sign.
|
||||
function roundRectPath(x: number, y: number, w: number, h: number, r: number, side: BarSide): string {
|
||||
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
const tl = side === "top" || side === "left" ? rr : 0;
|
||||
const tr = side === "top" || side === "right" ? rr : 0;
|
||||
const br = side === "bottom" || side === "right" ? rr : 0;
|
||||
const bl = side === "bottom" || side === "left" ? rr : 0;
|
||||
return `M${x + tl},${y} L${x + w - tr},${y} Q${x + w},${y} ${x + w},${y + tr}` +
|
||||
` L${x + w},${y + h - br} Q${x + w},${y + h} ${x + w - br},${y + h}` +
|
||||
` L${x + bl},${y + h} Q${x},${y + h} ${x},${y + h - bl}` +
|
||||
` L${x},${y + tl} Q${x},${y} ${x + tl},${y} Z`;
|
||||
}
|
||||
|
||||
// A bar extruded up-and-right by (dx, dy): a right side face (darkened), a top face
|
||||
// (lightened) and the front face. The overlays are flat black/white washes so the shading
|
||||
// needs no colour maths on a CSS variable it cannot read at build time.
|
||||
function bar3D(x: number, y: number, w: number, h: number, color: string, op: number, dx: number, dy: number): string {
|
||||
const top = `M${x},${y} L${x + dx},${y - dy} L${x + w + dx},${y - dy} L${x + w},${y} Z`;
|
||||
const right = `M${x + w},${y} L${x + w + dx},${y - dy} L${x + w + dx},${y + h - dy} L${x + w},${y + h} Z`;
|
||||
return `<path d="${right}" fill="${color}" fill-opacity="${op}"/><path d="${right}" fill="#000" fill-opacity="${0.24 * op}"/>` +
|
||||
`<path d="${top}" fill="${color}" fill-opacity="${op}"/><path d="${top}" fill="#fff" fill-opacity="${0.2 * op}"/>` +
|
||||
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${color}" fill-opacity="${op}"/>`;
|
||||
}
|
||||
|
||||
function linePathD(pts: [number, number][]): string {
|
||||
@@ -157,22 +177,25 @@ function smoothPathD(pts: [number, number][]): string {
|
||||
return d;
|
||||
}
|
||||
|
||||
function pointOnCircle(cx: number, cy: number, r: number, deg: number): [number, number] {
|
||||
// A point on a circle tilted about its horizontal axis by factor k (k=1 is upright): the
|
||||
// vertical radius shrinks to k·r, so the circle reads as an ellipse seen at an angle.
|
||||
function tiltPoint(cx: number, cy: number, r: number, deg: number, k: number): [number, number] {
|
||||
const a = (deg - 90) * Math.PI / 180; // 0° at 12 o'clock, clockwise
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
return [cx + r * Math.cos(a), cy + k * r * Math.sin(a)];
|
||||
}
|
||||
|
||||
// One pie/donut slice from a0 to a1 degrees. rIn === 0 gives a pie wedge.
|
||||
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number): string {
|
||||
// One pie/donut slice from a0 to a1 degrees, tilted by k (k=1 upright). rIn === 0 gives a
|
||||
// pie wedge. Uses elliptical arcs so the tilt is exact, not a polygon approximation.
|
||||
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number, k = 1): string {
|
||||
const large = a1 - a0 > 180 ? 1 : 0;
|
||||
const [ox0, oy0] = pointOnCircle(cx, cy, rOut, a0);
|
||||
const [ox1, oy1] = pointOnCircle(cx, cy, rOut, a1);
|
||||
const [ox0, oy0] = tiltPoint(cx, cy, rOut, a0, k);
|
||||
const [ox1, oy1] = tiltPoint(cx, cy, rOut, a1, k);
|
||||
if (rIn <= 0) {
|
||||
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
||||
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
||||
}
|
||||
const [ix1, iy1] = pointOnCircle(cx, cy, rIn, a1);
|
||||
const [ix0, iy0] = pointOnCircle(cx, cy, rIn, a0);
|
||||
return `M${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${rIn} 0 ${large} 0 ${ix0},${iy0} Z`;
|
||||
const [ix1, iy1] = tiltPoint(cx, cy, rIn, a1, k);
|
||||
const [ix0, iy0] = tiltPoint(cx, cy, rIn, a0, k);
|
||||
return `M${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${k * rIn} 0 ${large} 0 ${ix0},${iy0} Z`;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
@@ -223,16 +246,27 @@ interface SubProps {
|
||||
fmt: (v: number) => string;
|
||||
}
|
||||
|
||||
type BarSide = "top" | "bottom" | "left" | "right";
|
||||
interface BarMark { x: number; y: number; w: number; h: number; side: BarSide; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
||||
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
||||
|
||||
// ── cartesian (line / area / bar) ─────────────────────────────────────────────────
|
||||
|
||||
function CartesianChart(p: SubProps): JSXElement {
|
||||
const [hover, setHover] = createSignal<number | null>(null);
|
||||
const [pointerY, setPointerY] = createSignal(0);
|
||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||
|
||||
const horiz = () => p.props.kind === "bar" && !!p.props.horizontal;
|
||||
// 3D extrudes bars only; on a line/area it reads as noise, so it is a no-op there.
|
||||
const threeD = () => !!p.props.threeD && p.props.kind === "bar";
|
||||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||||
const dx = () => (threeD() ? depth() * 0.7 : 0);
|
||||
const dy = () => (threeD() ? depth() * 0.55 : 0);
|
||||
|
||||
const labels = () => p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? [];
|
||||
const n = () => Math.max(labels().length, ...p.props.series.map((s) => s.data.length), 0);
|
||||
|
||||
// The y domain. Stacked bars/areas reach the tallest STACK, not the tallest single
|
||||
// The value domain. Stacked bars/areas reach the tallest STACK, not the tallest single
|
||||
// value; bars and areas always include zero so the baseline is honest.
|
||||
const domain = createMemo(() => {
|
||||
const series = p.props.series;
|
||||
@@ -258,15 +292,24 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
return scale;
|
||||
});
|
||||
|
||||
// Left margin follows the widest y tick, so labels never clip and never float.
|
||||
// Margins: the value axis wants room for its ticks, the category axis for its labels —
|
||||
// which sides those are on flips with orientation. 3D adds depth to the top and right,
|
||||
// where bars extrude, so nothing clips.
|
||||
const layout = createMemo(() => {
|
||||
const d = domain();
|
||||
const showAxes = p.props.axes ?? true;
|
||||
const tickW = showAxes ? Math.max(...d.ticks.map((t) => p.fmt(t).length)) * 7 + 12 : 8;
|
||||
const left = Math.max(28, tickW);
|
||||
const top = 12;
|
||||
const bottom = showAxes ? 28 : 8;
|
||||
const right = 12;
|
||||
const valTickW = Math.max(...d.ticks.map((t) => p.fmt(t).length), 1) * 7 + 12;
|
||||
const catLabelW = Math.max(...labels().map((s) => s.length), 1) * 7 + 12;
|
||||
let left: number, bottom: number;
|
||||
if (horiz()) {
|
||||
left = showAxes ? Math.max(28, catLabelW) : 8; // category labels on the left
|
||||
bottom = showAxes ? 28 : 8; // value ticks on the bottom
|
||||
} else {
|
||||
left = showAxes ? Math.max(28, valTickW) : 8; // value ticks on the left
|
||||
bottom = showAxes ? 28 : 8; // category labels on the bottom
|
||||
}
|
||||
const top = 12 + dy();
|
||||
const right = 12 + dx();
|
||||
return {
|
||||
left, top, right, bottom,
|
||||
plotW: Math.max(0, p.width - left - right),
|
||||
@@ -274,28 +317,33 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
};
|
||||
});
|
||||
|
||||
const yToPx = (v: number) => {
|
||||
// valuePos: pixel along the VALUE axis (y for vertical, x for horizontal).
|
||||
// catCenter: pixel of category i along the CATEGORY axis (x for vertical, y for horizontal).
|
||||
const valuePos = (v: number) => {
|
||||
const d = domain(), l = layout();
|
||||
const t = (v - d.min) / (d.max - d.min || 1);
|
||||
return l.top + l.plotH * (1 - t);
|
||||
return horiz() ? l.left + l.plotW * t : l.top + l.plotH * (1 - t);
|
||||
};
|
||||
const bandW = () => layout().plotW / Math.max(1, n());
|
||||
const bandCenter = (i: number) => layout().left + bandW() * (i + 0.5);
|
||||
const baselineY = () => yToPx(clamp(0, domain().min, domain().max));
|
||||
const bandFull = () => (horiz() ? layout().plotH : layout().plotW) / Math.max(1, n());
|
||||
const catStart = () => (horiz() ? layout().top : layout().left);
|
||||
const catCenter = (i: number) => catStart() + bandFull() * (i + 0.5);
|
||||
const baseValue = () => valuePos(clamp(0, domain().min, domain().max));
|
||||
|
||||
// Grouped bar geometry: the series share a centred group that occupies ~72% of the
|
||||
// band; each bar is capped at BAR_MAX_W with a SEG_GAP of air between neighbours.
|
||||
// Bars resolved to plain rectangles + which side is the (rounded) data-end, so the
|
||||
// renderer draws vertical and horizontal bars the same way.
|
||||
const bars = createMemo(() => {
|
||||
if (p.props.kind !== "bar") return [] as BarMark[];
|
||||
const out: BarMark[] = [];
|
||||
const count = n(), bw = bandW(), base = baselineY();
|
||||
const count = n(), bf = bandFull(), base = baseValue(), h = horiz();
|
||||
const series = p.props.series;
|
||||
// rect(bandOffset, thickness, valueA, valueB) → a rectangle in the right orientation.
|
||||
const rect = (off: number, thick: number, va: number, vb: number): { x: number; y: number; w: number; h: number } =>
|
||||
h ? { x: Math.min(va, vb), y: off, w: Math.abs(vb - va), h: thick }
|
||||
: { x: off, y: Math.min(va, vb), w: thick, h: Math.abs(vb - va) };
|
||||
if (p.props.stacked) {
|
||||
const colW = Math.min(BAR_MAX_W, bw * 0.72);
|
||||
const thick = Math.min(BAR_MAX_W, bf * 0.72);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = layout().left + bw * i + (bw - colW) / 2;
|
||||
// The rounded data-end belongs to the OUTERMOST segment of each arm; the
|
||||
// interior boundaries are separated by the surface gap, not by rounding.
|
||||
const off = catStart() + bf * i + (bf - thick) / 2;
|
||||
let lastPos = -1, lastNeg = -1;
|
||||
for (let s = 0; s < series.length; s++) {
|
||||
const v = series[s].data[i] ?? 0;
|
||||
@@ -309,22 +357,25 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const to = from + v;
|
||||
if (v >= 0) accPos = to; else accNeg = to;
|
||||
const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg);
|
||||
const inset = isEnd ? 0 : SEG_GAP; // shrink toward the baseline for the 2px gap
|
||||
const yFrom = yToPx(from); // baseline-side edge
|
||||
const yVal = v >= 0 ? yToPx(to) + inset : yToPx(to) - inset;
|
||||
out.push({ x, w: colW, yBase: yFrom, yVal, seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
||||
const inset = isEnd ? 0 : SEG_GAP; // 2px surface gap between segments
|
||||
const vFrom = valuePos(from);
|
||||
// pull the value end toward the baseline by the gap (except the outer end)
|
||||
const vTo = valuePos(to) + (h ? (v >= 0 ? -inset : inset) : (v >= 0 ? inset : -inset));
|
||||
const r = rect(off, thick, vFrom, vTo);
|
||||
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nS = Math.max(1, series.length);
|
||||
const groupW = Math.min(bw * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
|
||||
const each = Math.max(1, Math.min(BAR_MAX_W, groupW / nS - SEG_GAP));
|
||||
const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
|
||||
const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const gx = layout().left + bw * i + (bw - groupW) / 2;
|
||||
const g = catStart() + bf * i + (bf - groupSize) / 2;
|
||||
for (let s = 0; s < nS; s++) {
|
||||
const v = series[s].data[i] ?? 0;
|
||||
const x = gx + s * (groupW / nS) + (groupW / nS - each) / 2;
|
||||
out.push({ x, w: each, yBase: base, yVal: yToPx(v), seriesIdx: s, catIdx: i, value: v, round: true });
|
||||
const off = g + s * (groupSize / nS) + (groupSize / nS - each) / 2;
|
||||
const r = rect(off, each, base, valuePos(v));
|
||||
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,7 +385,7 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
// Line/area paths, one per series. Stacked areas ride on the running total below.
|
||||
const paths = createMemo(() => {
|
||||
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
|
||||
const count = n(), base = baselineY();
|
||||
const count = n(), base = baseValue();
|
||||
const smooth = p.props.curve === "smooth";
|
||||
const stackAcc = new Array(count).fill(0);
|
||||
return p.props.series.map((s, si) => {
|
||||
@@ -344,8 +395,8 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const v = s.data[i] ?? 0;
|
||||
const yTop = p.props.stacked ? stackAcc[i] + v : v;
|
||||
const yBot = p.props.stacked ? stackAcc[i] : 0;
|
||||
pts.push([bandCenter(i), yToPx(yTop)]);
|
||||
lowerPts.push([bandCenter(i), p.props.stacked ? yToPx(yBot) : base]);
|
||||
pts.push([catCenter(i), valuePos(yTop)]);
|
||||
lowerPts.push([catCenter(i), p.props.stacked ? valuePos(yBot) : base]);
|
||||
if (p.props.stacked) stackAcc[i] = yTop;
|
||||
}
|
||||
const line = smooth ? smoothPathD(pts) : linePathD(pts);
|
||||
@@ -362,34 +413,59 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
// The whole SVG interior, as a string (see the file header for why innerHTML and not
|
||||
// JSX marks). Recomputed when the data, the size, or the hovered index changes.
|
||||
const body = createMemo(() => {
|
||||
const l = layout(), d = domain(), hv = hover();
|
||||
const l = layout(), d = domain(), hv = hover(), h = horiz();
|
||||
const out: string[] = [];
|
||||
|
||||
// gridlines + value ticks (perpendicular to the value axis)
|
||||
for (const t of d.ticks) {
|
||||
const y = yToPx(t);
|
||||
if (showGrid()) out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${y}" y2="${y}" stroke="var(--color-line)" stroke-width="1"/>`);
|
||||
if (showAxes()) out.push(`<text x="${l.left - 8}" y="${y}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
|
||||
const vp = valuePos(t);
|
||||
if (showGrid()) {
|
||||
out.push(h
|
||||
? `<line x1="${vp}" x2="${vp}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line)" stroke-width="1"/>`
|
||||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${vp}" y2="${vp}" stroke="var(--color-line)" stroke-width="1"/>`);
|
||||
}
|
||||
if (showAxes()) {
|
||||
out.push(h
|
||||
? `<text x="${vp}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`
|
||||
: `<text x="${l.left - 8}" y="${vp}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
|
||||
}
|
||||
}
|
||||
|
||||
const by = baselineY();
|
||||
out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${by}" y2="${by}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
// baseline (the value-0 line), a touch stronger than the grid
|
||||
const bv = baseValue();
|
||||
out.push(h
|
||||
? `<line x1="${bv}" x2="${bv}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`
|
||||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${bv}" y2="${bv}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
|
||||
// category labels (along the category axis)
|
||||
if (showAxes()) {
|
||||
labels().forEach((lab, i) =>
|
||||
out.push(`<text x="${bandCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
|
||||
out.push(h
|
||||
? `<text x="${l.left - 8}" y="${catCenter(i)}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`
|
||||
: `<text x="${catCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
|
||||
}
|
||||
|
||||
// crosshair (line/area only — a bar reader aims at a bar, not a hairline)
|
||||
if (p.props.tooltip !== false && hv !== null && !isBar()) {
|
||||
const x = bandCenter(hv);
|
||||
out.push(`<line x1="${x}" x2="${x}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
const c = catCenter(hv);
|
||||
out.push(`<line x1="${c}" x2="${c}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
}
|
||||
|
||||
for (const b of bars()) {
|
||||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||||
out.push(`<path d="${columnPath(b.x, b.w, b.yBase, b.yVal, b.round ? BAR_RADIUS : 0)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
||||
// bars — flat or extruded. 3D draws back-to-front so nearer bars overlap farther ones.
|
||||
const bs = bars();
|
||||
if (threeD()) {
|
||||
for (const b of bs) {
|
||||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||||
out.push(bar3D(b.x, b.y, b.w, b.h, esc(p.colorOf(b.seriesIdx)), op, dx(), dy()));
|
||||
}
|
||||
} else {
|
||||
for (const b of bs) {
|
||||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||||
out.push(`<path d="${roundRectPath(b.x, b.y, b.w, b.h, b.round ? BAR_RADIUS : 0, b.side)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
// areas then lines (3D does not apply — depth reads as noise on a line).
|
||||
for (const pth of paths()) {
|
||||
if (p.props.kind === "area") out.push(`<path d="${pth.area}" fill="${esc(p.colorOf(pth.seriesIdx))}" fill-opacity="0.1"/>`);
|
||||
}
|
||||
@@ -411,11 +487,15 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (p.props.tooltip === false) return;
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const idx = clamp(Math.floor((e.clientX - rect.left - layout().left) / bandW()), 0, Math.max(0, n() - 1));
|
||||
setHover(idx);
|
||||
setPointerY(e.clientY - rect.top);
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
const along = horiz() ? py - layout().top : px - layout().left;
|
||||
setHover(clamp(Math.floor(along / bandFull()), 0, Math.max(0, n() - 1)));
|
||||
setPointer([px, py]);
|
||||
};
|
||||
|
||||
const anchorX = () => (horiz() ? pointer()[0] : catCenter(hover()!));
|
||||
const anchorY = () => (horiz() ? catCenter(hover()!) : pointer()[1]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block overflow-visible"
|
||||
@@ -424,14 +504,15 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
<CartesianTooltip
|
||||
props={p.props} colorOf={p.colorOf} fmt={p.fmt}
|
||||
index={hover()!} label={labels()[hover()!] ?? ""}
|
||||
anchorX={bandCenter(hover()!)} anchorY={pointerY()} width={p.width} height={p.height} />
|
||||
anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} />
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
||||
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
||||
function barSide(horiz: boolean, v: number): BarSide {
|
||||
return horiz ? (v >= 0 ? "right" : "left") : (v >= 0 ? "top" : "bottom");
|
||||
}
|
||||
|
||||
function CartesianTooltip(p: {
|
||||
props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string;
|
||||
@@ -466,19 +547,26 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
const [hover, setHover] = createSignal<number | null>(null);
|
||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||
|
||||
const threeD = () => !!p.props.threeD;
|
||||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||||
const tilt = () => (threeD() ? 0.62 : 1); // vertical squash of the disc when tilted
|
||||
|
||||
const values = () => p.props.series[0]?.data ?? [];
|
||||
const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1));
|
||||
const total = () => values().reduce((a, v) => a + Math.max(0, v), 0);
|
||||
|
||||
const geo = () => {
|
||||
const cx = p.width / 2, cy = p.height / 2;
|
||||
const rOut = Math.max(0, Math.min(p.width, p.height) / 2 - 8);
|
||||
const k = tilt();
|
||||
const cx = p.width / 2;
|
||||
// tilting shrinks the disc's height to k·2r and adds `depth` below; keep it centred.
|
||||
const rOut = Math.max(0, Math.min(p.width, p.height - (threeD() ? depth() : 0)) / 2 - 8);
|
||||
const cy = p.height / 2 - (threeD() ? depth() / 2 : 0);
|
||||
const rIn = p.props.kind === "donut" ? rOut * (p.props.donutRatio ?? 0.6) : 0;
|
||||
return { cx, cy, rOut, rIn };
|
||||
return { cx, cy, rOut, rIn, k };
|
||||
};
|
||||
|
||||
// Slices with their angular spans. A lone value becomes a full ring (drawn as a
|
||||
// circle, since an arc from 0° to 360° collapses).
|
||||
// circle/ellipse, since an arc from 0° to 360° collapses).
|
||||
const slices = createMemo(() => {
|
||||
const t = total();
|
||||
const out: { idx: number; a0: number; a1: number; value: number }[] = [];
|
||||
@@ -491,20 +579,44 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
return out;
|
||||
});
|
||||
|
||||
// The extruded rim under one slice: the front-facing part of its outer arc (angles
|
||||
// 90°–270°, where the ellipse edge dips below centre) swept down by `depth`.
|
||||
const wall = (g: ReturnType<typeof geo>, a0: number, a1: number): string => {
|
||||
const w0 = Math.max(a0, 90), w1 = Math.min(a1, 270);
|
||||
if (w1 <= w0) return "";
|
||||
const [x0, y0] = tiltPoint(g.cx, g.cy, g.rOut, w0, g.k);
|
||||
const [x1, y1] = tiltPoint(g.cx, g.cy, g.rOut, w1, g.k);
|
||||
const large = w1 - w0 > 180 ? 1 : 0;
|
||||
return `M${x0},${y0} A${g.rOut},${g.k * g.rOut} 0 ${large} 1 ${x1},${y1}` +
|
||||
` L${x1},${y1 + depth()} A${g.rOut},${g.k * g.rOut} 0 ${large} 0 ${x0},${y0 + depth()} Z`;
|
||||
};
|
||||
|
||||
const body = createMemo(() => {
|
||||
const g = geo(), hv = hover();
|
||||
const out: string[] = [];
|
||||
const positive = slices().filter((s) => s.value > 0);
|
||||
if (positive.length === 1) {
|
||||
// one value: a full ring, since a 360° arc collapses to nothing
|
||||
const single = positive.length === 1;
|
||||
|
||||
// 3D: draw every slice's rim first (the disc's thickness), then the top faces on top.
|
||||
if (threeD()) {
|
||||
for (const s of single ? positive : slices()) {
|
||||
if (s.a1 <= s.a0) continue;
|
||||
const d = single ? wall(g, 90, 270) : wall(g, s.a0, s.a1);
|
||||
if (!d) continue;
|
||||
const c = esc(p.colorOf(s.idx));
|
||||
out.push(`<path d="${d}" fill="${c}"/><path d="${d}" fill="#000" fill-opacity="0.3"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (single) {
|
||||
const s = positive[0];
|
||||
out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rOut}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
||||
if (g.rIn > 0) out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rIn}" fill="var(--color-surface)"/>`);
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, 0, 0, 359.999, g.k)}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
||||
if (g.rIn > 0) out.push(`<ellipse cx="${g.cx}" cy="${g.cy}" rx="${g.rIn}" ry="${g.k * g.rIn}" fill="var(--color-surface)"/>`);
|
||||
} else {
|
||||
for (const s of slices()) {
|
||||
if (s.a1 <= s.a0) continue;
|
||||
const op = hv === null || hv === s.idx ? 1 : 0.55;
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1, g.k)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
@@ -515,11 +627,9 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const g = geo();
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
const dx = px - g.cx, dy = py - g.cy;
|
||||
const dx = px - g.cx, dy = (py - g.cy) / g.k; // undo the tilt to test against a circle
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; }
|
||||
// pointOnCircle maps deg (from 12 o'clock, clockwise) to (cos(deg-90), sin(deg-90));
|
||||
// invert it: the pointer's slice angle is atan2(dy,dx) shifted back by 90°.
|
||||
let deg = (Math.atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360;
|
||||
const s = slices().find((s) => s.value > 0 && deg >= s.a0 && deg < s.a1);
|
||||
setHover(s ? s.idx : null);
|
||||
|
||||
Reference in New Issue
Block a user