export const LINE_COLOR = "#4e79a7";
export const SECOND_COLOR = "#f28e2b";
const UP_COLOR = "#2e9e5b", DOWN_COLOR = "#e05759";
const FONT_PX = "11px";
export function fmtVal(value, unit) {
if (value == null) return "—";
if (unit === "bool") return value ? "yes" : "no";
const v = Number(value);
if (unit === "percent") return (v * 100).toFixed(2) + "%";
if (unit === "money") {
const a = Math.abs(v);
for (const [u, s] of [["T", 1e12], ["B", 1e9], ["M", 1e6], ["K", 1e3]])
if (a >= s) return "$" + (v / s).toFixed(2) + u;
return "$" + v.toFixed(2);
}
if (unit === "count")
return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2);
return v.toFixed(2); }
export function axisFmt(unit) {
if (unit === "percent") return (v) => d3.format(".0%")(v);
if (unit === "money") return (v) => "$" + d3.format("~s")(v);
return (v) => d3.format("~g")(v);
}
export function esc(s) { return s == null ? "" : String(s).replace(/</g, "<"); }
export function makeYScale(values, scale, ih) {
let ext = d3.extent(values);
if (ext[0] == null) ext = [0, 1];
const logOK = scale === "log" && ext[0] > 0;
let dom = ext;
if (dom[0] === dom[1]) { const pad = Math.abs(dom[0]) * 0.05 || 1;
dom = [dom[0] - pad, dom[0] + pad];
}
const ys = (logOK ? d3.scaleLog() : d3.scaleLinear())
.domain(dom).nice().range([ih, 0]);
ys._logOK = logOK;
return ys;
}
export function colorAxis(axisG, color) {
axisG.selectAll("path.domain").attr("stroke", color);
axisG.selectAll("line").attr("stroke", color);
axisG.selectAll("text").attr("fill", color);
}
const seriesValues = (s) =>
s.isOHLC ? s.pts.flatMap(p => [p.l, p.h]) : s.pts.map(p => p.value);
export function drawOHLC(g, pts, xs, ys, iw, style) {
const n = pts.length;
const cw = Math.max(4, Math.min(30, (iw / n) * 0.9)); const col = d => (d.c >= d.o ? UP_COLOR : DOWN_COLOR);
if (style === "candle") {
const sel = g.append("g").attr("class", "lp-candles");
sel.selectAll("line").data(pts).enter().append("line")
.attr("x1", d => xs(d.date)).attr("x2", d => xs(d.date))
.attr("y1", d => ys(d.h)).attr("y2", d => ys(d.l))
.attr("stroke", col).attr("stroke-width", 1);
sel.selectAll("rect").data(pts).enter().append("rect")
.attr("x", d => xs(d.date) - cw / 2)
.attr("y", d => Math.min(ys(d.o), ys(d.c)))
.attr("width", cw)
.attr("height", d => Math.max(2, Math.abs(ys(d.c) - ys(d.o))))
.attr("fill", col).attr("stroke", col).attr("stroke-width", 0.5);
} else {
const t = Math.max(2, cw / 2);
const sel = g.append("g").attr("class", "lp-bars");
sel.selectAll("line.stem").data(pts).enter().append("line").attr("class", "stem")
.attr("x1", d => xs(d.date)).attr("x2", d => xs(d.date))
.attr("y1", d => ys(d.h)).attr("y2", d => ys(d.l))
.attr("stroke", col).attr("stroke-width", 1.2);
sel.selectAll("line.open").data(pts).enter().append("line").attr("class", "open")
.attr("x1", d => xs(d.date) - t).attr("x2", d => xs(d.date))
.attr("y1", d => ys(d.o)).attr("y2", d => ys(d.o))
.attr("stroke", col).attr("stroke-width", 1.2);
sel.selectAll("line.close").data(pts).enter().append("line").attr("class", "close")
.attr("x1", d => xs(d.date)).attr("x2", d => xs(d.date) + t)
.attr("y1", d => ys(d.c)).attr("y2", d => ys(d.c))
.attr("stroke", col).attr("stroke-width", 1.2);
}
}
export function drawLinePanel(g, region, spec, tip) {
const series = (spec.series || []).filter(s => s && s.pts && s.pts.length);
if (!series.length) return;
const scale = spec.scale || "linear";
const left = series.filter(s => s.axis !== "right");
const right = series.filter(s => s.axis === "right");
const legend = !!spec.legend && series.length > 1;
const M = {
top: 18,
right: right.length ? 70 : (legend ? 168 : 28),
bottom: 38,
left: 78,
};
const iw = Math.max(10, region.w - M.left - M.right);
const ih = Math.max(10, region.h - M.top - M.bottom);
g.attr("transform", `translate(${region.x + M.left},${region.y + M.top})`);
let xDomain = [
d3.min(series, s => d3.min(s.pts, p => p.date)),
d3.max(series, s => d3.max(s.pts, p => p.date)),
];
if (+xDomain[0] === +xDomain[1]) {
const day = 864e5;
xDomain = [new Date(+xDomain[0] - day), new Date(+xDomain[1] + day)];
}
const xs = d3.scaleUtc().domain(xDomain).range([0, iw]);
const ysL = makeYScale(left.flatMap(seriesValues), scale, ih);
const ysR = right.length ? makeYScale(right.flatMap(seriesValues), scale, ih) : null;
const yOf = (s) => (s.axis === "right" ? ysR : ysL);
const logOK = scale === "log" && ysL._logOK;
const logFellBack = scale === "log" && !ysL._logOK;
const xTicks = xs.ticks(Math.max(2, Math.round(iw / 110)));
const yTicks = ysL.ticks(Math.max(3, Math.round(ih / 48)));
if (spec.gridlines !== false) {
const grid = g.append("g").attr("class", "lp-grid");
grid.selectAll("line.gx").data(xTicks).enter().append("line").attr("class", "gx")
.attr("x1", d => xs(d)).attr("x2", d => xs(d)).attr("y1", 0).attr("y2", ih);
grid.selectAll("line.gy").data(yTicks).enter().append("line").attr("class", "gy")
.attr("x1", 0).attr("x2", iw).attr("y1", d => ysL(d)).attr("y2", d => ysL(d));
}
g.append("g").attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(xs).tickValues(xTicks)).style("font-size", FONT_PX);
const leftAxisG = g.append("g").attr("class", "lp-yaxis")
.call(d3.axisLeft(ysL).tickValues(yTicks).tickFormat(axisFmt(left[0] && left[0].unit)))
.style("font-size", FONT_PX);
if (spec.leftAxisColor) colorAxis(leftAxisG, spec.leftAxisColor);
const leftUnit = left[0] && left[0].unit;
const leftLabel = spec.yLabel != null ? spec.yLabel
: (left.length === 1
? left[0].label + (leftUnit && leftUnit !== "money" ? ` (${leftUnit})` : "")
: "");
const labelText = leftLabel
+ (logOK ? " · log" : logFellBack ? " · log n/a (≤0)" : "");
if (labelText.trim()) {
g.append("text").attr("class", "lp-axis-label")
.attr("transform", "rotate(-90)").attr("x", -(ih / 2)).attr("y", -(M.left - 16))
.attr("text-anchor", "middle").style("font-size", FONT_PX)
.style("fill", spec.leftAxisColor || null).text(labelText);
}
if (ysR) {
const r0 = right[0];
const yrTicks = ysR.ticks(Math.max(3, Math.round(ih / 48)));
const axG = g.append("g").attr("transform", `translate(${iw},0)`)
.call(d3.axisRight(ysR).tickValues(yrTicks).tickFormat(axisFmt(r0.unit)))
.style("font-size", FONT_PX);
colorAxis(axG, r0.color || SECOND_COLOR);
const rLabel = r0.label + (r0.unit && r0.unit !== "money" ? ` (${r0.unit})` : "");
g.append("text").attr("class", "lp-axis-label")
.attr("transform", "rotate(-90)").attr("x", -(ih / 2)).attr("y", iw + M.right - 14)
.attr("text-anchor", "middle").style("font-size", FONT_PX)
.style("fill", r0.color || SECOND_COLOR).text(rLabel);
}
const curve = spec.curve || d3.curveLinear;
for (const s of series) {
const ys = yOf(s);
const kind = s.kind || "line";
if (kind === "candle" || kind === "bars") {
drawOHLC(g, s.pts, xs, ys, iw, kind);
continue;
}
if (kind === "area") {
const area = d3.area().x(d => xs(d.date)).y0(ih).y1(d => ys(d.value)).curve(curve);
g.append("path").datum(s.pts).attr("class", "lp-area").attr("d", area)
.style("fill", s.color === LINE_COLOR ? null : s.color);
}
if (s.pts.length >= 2) {
g.append("path").datum(s.pts).attr("fill", "none")
.attr("stroke", s.color).attr("stroke-width", s.dashed ? 1.5 : 1.8)
.attr("stroke-dasharray", s.dashed ? "4 3" : null)
.attr("d", d3.line().x(d => xs(d.date)).y(d => ys(d.value)).curve(curve));
}
if (s.pts.length <= 80 && kind !== "area") {
g.selectAll(null).data(s.pts).enter().append("circle")
.attr("cx", d => xs(d.date)).attr("cy", d => ys(d.value))
.attr("r", s.pts.length === 1 ? 4 : 2.6).attr("fill", s.color);
}
}
if (legend) {
const lg = g.append("g").attr("transform", `translate(${iw + 14},0)`);
series.forEach((s, i) => {
const row = lg.append("g").attr("transform", `translate(0,${i * 16})`);
row.append("rect").attr("width", 12).attr("height", 12)
.attr("fill", s.color).attr("fill-opacity", 0.95)
.attr("stroke", "#1a1a1a").attr("stroke-width", 0.4);
row.append("text").attr("x", 16).attr("y", 10).style("font-size", FONT_PX)
.style("fill", "var(--color-fg)")
.text(s.label.length > 20 ? s.label.slice(0, 19) + "…" : s.label);
});
}
const vline = g.append("line").attr("class", "lp-crosshair")
.attr("y1", 0).attr("y2", ih).style("display", "none");
const hline = g.append("line").attr("class", "lp-crosshair")
.attr("x1", 0).attr("x2", iw).style("display", "none");
const foci = series.map(s => g.append("circle").attr("class", "lp-focus")
.attr("r", 4).style("stroke", s.color).style("display", "none"));
const bisect = d3.bisector(d => d.date).left;
const fmtDate = d3.utcFormat("%b %-d, %Y");
const svgNode = g.node().ownerSVGElement;
const snap = (list, x0) => {
let i = bisect(list, x0);
if (i >= list.length) i = list.length - 1;
if (i > 0 && (+x0 - +list[i - 1].date) < (+list[i].date - +x0)) i -= 1;
return list[i];
};
const ref = series.reduce((a, b) => (b.pts.length > a.pts.length ? b : a), series[0]);
g.append("rect").attr("width", iw).attr("height", ih)
.attr("fill", "none").attr("pointer-events", "all")
.on("pointermove", (event) => {
const [mx, my] = d3.pointer(event, g.node());
const x0 = xs.invert(mx);
const anchor = snap(ref.pts, x0);
const px = xs(anchor.date);
const hy = Math.max(0, Math.min(ih, my));
vline.attr("x1", px).attr("x2", px).style("display", null);
hline.attr("y1", hy).attr("y2", hy).style("display", null);
const rows = series.map((s, i) => {
const d = snap(s.pts, x0);
const near = Math.abs(+d.date - +anchor.date) < 4 * 864e5
|| series.length === 1;
if (near) foci[i].attr("cx", xs(d.date)).attr("cy", yOf(s)(d.value))
.style("display", null);
else { foci[i].style("display", "none"); return ""; }
if (s.isOHLC)
return `<span class="tt-ohlc" style="color:${s.color}">O ${fmtVal(d.o, s.unit)}`
+ ` H ${fmtVal(d.h, s.unit)} L ${fmtVal(d.l, s.unit)} C ${fmtVal(d.c, s.unit)}</span>`;
return `<span class="tt-val" style="color:${s.color}">`
+ `${esc(s.label)}: ${fmtVal(d.value, s.unit)}</span>`;
}).join("");
tip.html(`<span class="tt-date">${fmtDate(anchor.date)}</span>${rows}`)
.style("display", null);
const tw = tip.node().offsetWidth, th = tip.node().offsetHeight;
const ox = region.x + M.left, oy = region.y + M.top;
const svgW = svgNode ? (+svgNode.getAttribute("width") || svgNode.clientWidth)
: region.x + region.w;
let lx = ox + px + 14, ly = oy + Math.max(0, my) - th - 10;
if (lx + tw > svgW) lx = ox + px - tw - 14;
if (ly < 0) ly = oy + Math.max(0, my) + 12;
tip.style("left", lx + "px").style("top", ly + "px");
})
.on("pointerleave", () => {
vline.style("display", "none");
hline.style("display", "none");
foci.forEach(f => f.style("display", "none"));
tip.style("display", "none");
});
}
export function mountChart(canvas, spec, emptyMsg = "No data.") {
canvas.style.position = "relative";
const render = () => {
canvas.innerHTML = "";
const series = (spec.series || []).filter(s => s && s.pts && s.pts.length);
if (!series.length) {
canvas.innerHTML = `<p class="muted">${esc(emptyMsg)}</p>`;
return;
}
let captionH = 0;
if (spec.caption) {
const p = document.createElement("p");
p.className = "muted"; p.style.margin = "0 0 4px"; p.textContent = spec.caption;
canvas.appendChild(p);
captionH = p.offsetHeight + 4;
}
const W = canvas.clientWidth || 900;
const H = Math.max(spec.minHeight || 420, canvas.clientHeight - captionH) || 480;
const svg = d3.select(canvas).append("svg")
.attr("class", "lp-canvas")
.attr("viewBox", `0 0 ${W} ${H}`).attr("width", W).attr("height", H)
.style("width", W + "px").style("height", H + "px");
const tip = d3.select(canvas).append("div").attr("class", "lp-tooltip")
.style("display", "none");
drawLinePanel(svg.append("g"), { x: 0, y: 0, w: W, h: H }, spec, tip);
};
render();
if (!canvas._lpRO && typeof ResizeObserver !== "undefined") {
let raf = 0, lastW = canvas.clientWidth;
canvas._lpRO = new ResizeObserver(() => {
if (canvas.clientWidth === lastW) return; lastW = canvas.clientWidth;
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => { raf = 0; if (canvas.clientWidth) render(); });
});
canvas._lpRO.observe(canvas);
}
return { redraw: render };
}