taxa-server 0.1.0

axum web server for taxa: reproduces the HTTP contract + serves the embedded D3 frontend.
// Shared line/OHLC plotting core — used by BOTH the individual-entity chart
// (viz/entity.js, 1-2 series + OHLC + overlay/subplots) and the group
// per-branch chart (viz/timeseries.js, N branch lines + legend). Keeping
// one renderer means gridlines, crosshair, tooltip, log scale, unit-aware
// axes and constant-font sizing behave identically across both views.
//
// The chart renders at an EXPLICIT pixel size (no viewBox scaling) so axis
// and tick fonts stay constant regardless of container width; mountChart()
// installs a debounced ResizeObserver that re-lays-out on resize.
//
// Data model — a "series":
//   { label, color, unit, pts:[{date:Date, value:Number}],
//     kind?: 'line'|'area'|'candle'|'bars',   // default 'line'
//     axis?: 'left'|'right',                   // default 'left'
//     dashed?: bool }                          // dashed stroke (e.g. "Other")
// OHLC series additionally carry o/h/l/c on each pt and isOHLC:true.
//
// d3 is a global (CDN), same as the other viz modules.

export const LINE_COLOR = "#4e79a7";
export const SECOND_COLOR = "#f28e2b";
const UP_COLOR = "#2e9e5b", DOWN_COLOR = "#e05759";
const FONT_PX = "11px";

// ── unit-aware value formatting (mirrors cli/get.py:_fmt_metric) ──────
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);   // ratio / index / fallback
}

// Y-axis tick formatter by unit (compact, scale-agnostic).
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, "&lt;"); }

// Build a y-scale (linear or log) over a flat numeric array, padding a
// degenerate single-value domain and recording whether a requested log
// scale was usable (all-positive).
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]) {                 // single value → pad ±5%
    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;
}

// Tint an axis <g> (line, ticks, tick-text) a single color.
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);

// ── Candlestick / OHLC-bar marks. Up (close ≥ open) green, down red. ──
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));   // body / tick width
  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);
  }
}

// ── Reusable multi-series panel renderer ──────────────────────────────
// g      : a <g> appended to the svg (no transform yet).
// region : { x, y, w, h } pixel offset+size of this panel within the svg.
// spec   : { series:[…], scale:'linear'|'log', curve?, gridlines?, legend?,
//            yLabel? }  — yLabel overrides the derived left-axis label.
// tip    : a shared absolutely-positioned HTML tooltip selection.
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})`);

  // X scale (UTC: ISO dates parse as UTC midnight, so ticks/gridlines must
  // be UTC too or each point sits offset from its own gridline).
  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]);

  // Y scales — one for the left-axis series (shared), one for right.
  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)));

  // Dotted gridlines (behind data).
  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));
  }

  // Axes.
  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);

  // Left y-axis label: explicit override, else the single series' label
  // (with unit + log hint). Suppressed when many left series share the axis
  // (the legend identifies them instead).
  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);
  }

  // Right axis (orange) for an overlaid right-axis series.
  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);
  }

  // ── Data marks, per series by kind ────────────────────────────────
  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);
    }
  }

  // ── Legend (right gutter) ─────────────────────────────────────────
  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);
    });
  }

  // ── Crosshair + per-series focus dots + multi-series tooltip ───────
  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];
  };
  // Reference series for x-snapping = the densest one.
  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);
        // Only show a focus/row when the snapped point is near the anchor x.
        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");
    });
}

// ── Full-canvas mount: pixel-sized SVG + tooltip + ResizeObserver ─────
// spec is a drawLinePanel spec; emptyMsg shows when there are no series.
// Returns { redraw } so callers can force a relayout of the same spec.
export function mountChart(canvas, spec, emptyMsg = "No data.") {
  canvas.style.position = "relative";   // anchor the absolute tooltip

  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;
    }
    // Optional caption above the chart (e.g. the group "no history" notice).
    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;   // height-only changes ignored
      lastW = canvas.clientWidth;
      if (raf) cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => { raf = 0; if (canvas.clientWidth) render(); });
    });
    canvas._lpRO.observe(canvas);
  }
  return { redraw: render };
}