taxa-server 0.1.0

axum web server for taxa: reproduces the HTTP contract + serves the embedded D3 frontend.
// Individual-entity view: split panel — metadata + headline metrics on
// the left, a price/metric chart on the right. Window, metric, scale,
// resolution, style, a second metric, and the tiling mode are owned by
// the entity sidebar (selection.js); we re-fetch / redraw on its events.
//
// The chart renders at an EXPLICIT pixel size (no viewBox scaling) so the
// axis / tick fonts stay constant regardless of the browser/container
// width. A ResizeObserver re-lays-out the cached data on container resize
// (debounced via requestAnimationFrame) so text never scales.
//
// The actual rendering is delegated to the shared viz/lineplot.js core
// (drawLinePanel) — the same renderer the group per-branch chart uses.
// This module owns the entity-specific orchestration: the metadata panel,
// OHLC fetch, and the tiling layout (one drawLinePanel call per region):
//   • overlay  — one region: primary series + an overlaid right-axis
//                secondary line (orange), sharing one crosshair.
//   • vertical / horizontal / grid — one region per metric, each an
//                independent panel (own scales, axes, crosshair, tooltip).

import { API } from "/static/api.js";
import { getEntityWindow, getEntityMetric, getEntityScale,
         getEntityResolution, getEntityStyle, getEntityShowMeta,
         getEntityMetric2, getEntityTiling }
  from "/static/selection.js";
import { fmtVal, esc, drawLinePanel, LINE_COLOR, SECOND_COLOR }
  from "/static/viz/lineplot.js";

let _symbol = null;     // currently-displayed entity
let _overview = null;
// Last-rendered chart spec (redrawn on scale/resize/meta-toggle without a
// refetch). { primary, secondary|null, style, scale, tiling }.
let _chart = null;
let _ro = null;         // ResizeObserver on #ent-canvas (set up once)
let _raf = 0;           // pending rAF id for the debounced resize redraw

export function setupEntity(_metrics) {
  // Sidebar emits these on user changes. Window / metric / resolution /
  // metric2 change the data → refetch. Style may switch between line and
  // OHLC sources, so it refetches too. Scale, tiling, and meta are pure
  // redraws of the cached spec.
  document.addEventListener("entity-window-changed", renderChart);
  document.addEventListener("entity-metric-changed", renderChart);
  document.addEventListener("entity-resolution-changed", renderChart);
  document.addEventListener("entity-style-changed", renderChart);
  document.addEventListener("entity-metric2-changed", renderChart);
  document.addEventListener("entity-scale-changed", redrawCached);
  document.addEventListener("entity-tiling-changed", redrawCached);
  // Show/hide the metadata panel — no refetch; toggle the class and
  // redraw so the chart reclaims/relinquishes the width.
  document.addEventListener("entity-meta-toggled", () => {
    const split = document.querySelector(".entity-split");
    if (split) split.classList.toggle("meta-hidden", !getEntityShowMeta());
    redrawCached();
  });
}

// Redraw the cached spec (no refetch). Reads scale/tiling fresh since
// those mutate the spec in place. Guards against missing data / 0-width.
function redrawCached() {
  if (!_chart) return;
  _chart.scale = getEntityScale();
  _chart.tiling = getEntityTiling();
  const canvas = document.querySelector("#ent-canvas");
  if (!canvas || !canvas.clientWidth) return;
  draw(canvas, _chart);
}

// Install the ResizeObserver once. On container width changes we re-run
// the draw of the cached spec at the true pixel width (debounced to one
// rAF) so the SVG never has to scale → fonts stay constant.
function ensureResizeObserver(canvas) {
  if (_ro || typeof ResizeObserver === "undefined") return;
  _ro = new ResizeObserver(() => {
    if (_raf) cancelAnimationFrame(_raf);
    _raf = requestAnimationFrame(() => {
      _raf = 0;
      if (!_chart) return;
      const c = document.querySelector("#ent-canvas");
      if (!c || !c.clientWidth) return;
      draw(c, _chart);
    });
  });
  _ro.observe(canvas);
}

export async function renderEntity(symbol) {
  _symbol = symbol;
  _chart = null;
  const root = document.querySelector("#tab-entity");
  if (!root) return;
  root.innerHTML = `
    <div class="entity-split${getEntityShowMeta() ? "" : " meta-hidden"}">
      <div class="entity-meta" id="ent-meta"><p class="muted">Loading</p></div>
      <div class="entity-chart"><div id="ent-canvas"></div></div>
    </div>`;
  try {
    _overview = await API.entity(symbol);
  } catch (e) {
    document.querySelector("#ent-meta").innerHTML =
      `<p class="err">${e.message}</p>`;
    return;
  }
  renderMeta();
  const canvas = document.querySelector("#ent-canvas");
  if (canvas) ensureResizeObserver(canvas);
  renderChart();
}

// A fact value that is a URL renders as a link; everything else is escaped text.
// Conservative: only http(s):// and www.* are linkified (href is escaped).
function factHtml(v) {
  const s = String(v);
  if (/^https?:\/\/\S+$/i.test(s))
    return `<a href="${esc(s)}" target="_blank" rel="noopener">${esc(s)}</a>`;
  if (/^www\.\S+$/i.test(s))
    return `<a href="https://${esc(s)}" target="_blank" rel="noopener">${esc(s)}</a>`;
  return esc(s);
}

// Generic: render the provider's detail payload — {label, facts:[{label,
// value}], metrics:[{label,value,unit}]} — with no dataset-specific fields.
function renderMeta() {
  const o = _overview;
  const meta = document.querySelector("#ent-meta");
  if (!o) { meta.innerHTML = `<p class="err">No data.</p>`; return; }

  const facts = (o.facts || []).filter(f => f.value != null && f.value !== "");
  const metricRows = (o.metrics || [])
    .map(m => `<tr><td class="ent-mlabel">${esc(m.label)}</td>
                   <td class="ent-mval">${fmtVal(m.value, m.unit)}</td></tr>`)
    .join("");

  meta.innerHTML = `
    <div class="ent-head">
      <h2 class="ent-name">${esc(o.label || _symbol)}</h2>
      <div class="ent-sym">${esc(_symbol)}</div>
    </div>
    ${facts.length ? `<dl class="ent-facts">${facts.map(f =>
        `<div><dt>${esc(f.label)}</dt><dd>${factHtml(f.value)}</dd></div>`).join("")}</dl>` : ""}
    ${metricRows ? `<table class="ent-metrics"><tbody>${metricRows}</tbody></table>` : ""}
  `;
}

// ── Build a normalized dataset from a /series or /ohlc payload ─────────
// Returns { pts, unit, label, isOHLC } where pts is the cleaned point
// list (value=close for OHLC) or null if there are no usable points.
function buildDataset(s) {
  const isOHLC = !!s._ohlc;
  const pts = [];
  if (isOHLC) {
    for (let i = 0; i < s.dates.length; i++) {
      const o = s.open[i], h = s.high[i], l = s.low[i], c = s.close[i];
      if ([o, h, l, c].some(x => x == null || Number.isNaN(x))) continue;
      pts.push({ date: new Date(s.dates[i]), o: +o, h: +h, l: +l, c: +c, value: +c });
    }
  } else {
    for (let i = 0; i < s.dates.length; i++) {
      const v = s.values[i];
      if (v == null || Number.isNaN(v)) continue;
      pts.push({ date: new Date(s.dates[i]), value: +v });
    }
  }
  if (pts.length === 0) return null;
  return { pts, unit: s.unit, label: s.label, isOHLC };
}

async function renderChart() {
  if (!_symbol) return;
  const canvas = document.querySelector("#ent-canvas");
  if (!canvas) return;
  const metric = getEntityMetric();
  if (!metric) {                    // dataset with no chartable series metric
    canvas.innerHTML = "";
    return;
  }
  const metric2 = getEntityMetric2();
  const window_ = getEntityWindow();
  const resolution = getEntityResolution();
  const style = getEntityStyle();
  const tiling = getEntityTiling();
  // Candlestick / OHLC bars need open-high-low-close, which only exists
  // for price; any other metric falls back to the line/area series.
  const wantOHLC = (style === "candle" || style === "bars") && metric === "price";
  canvas.innerHTML = `<p class="muted">Loading</p>`;

  let primaryRaw, secondaryRaw = null;
  try {
    primaryRaw = wantOHLC
      ? await API.entityOhlc(_symbol, window_, resolution)
      : await API.entitySeries(_symbol, metric, window_, resolution);
    primaryRaw._ohlc = wantOHLC;
    // The second metric is always a plain line series (never OHLC), even
    // when the primary is a candlestick.
    if (metric2)
      secondaryRaw = await API.entitySeries(_symbol, metric2, window_, resolution);
  } catch (e) {
    canvas.innerHTML = `<p class="err">${e.message}</p>`;
    return;
  }

  const primary = buildDataset(primaryRaw);
  const secondary = secondaryRaw ? buildDataset(secondaryRaw) : null;

  _chart = { primary, secondary, style, scale: getEntityScale(), tiling };
  draw(canvas, _chart);
}

// ── Top-level draw: pixel-sized SVG + layout dispatch (shared renderer) ─
function draw(canvas, chart) {
  canvas.innerHTML = "";
  const { primary, secondary, style, scale, tiling } = chart;
  if (!primary) {
    canvas.innerHTML = `<p class="muted">No data for this window.</p>`;
    return;
  }
  const W = canvas.clientWidth || 900;
  const H = Math.max(380, canvas.clientHeight) || 480;

  // Explicit-pixel SVG so axis/tick fonts never scale with the container.
  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");

  // Map a buildDataset() result -> a lineplot series. OHLC styles downgrade
  // to a line when the dataset isn't OHLC (e.g. a derived metric).
  const toSeries = (ds, { kind, color, axis }) => ({
    label: ds.label, color, unit: ds.unit, axis, isOHLC: ds.isOHLC,
    kind: (kind === "candle" || kind === "bars") && !ds.isOHLC ? "line" : kind,
    pts: ds.pts,
  });

  const wantSubplots = secondary && tiling && tiling !== "overlay";
  if (!wantSubplots) {
    // One region: primary (left axis) + optional secondary (orange, right axis).
    const series = [toSeries(primary, { kind: style, color: LINE_COLOR, axis: "left" })];
    if (secondary)
      series.push(toSeries(secondary, { kind: "line", color: SECOND_COLOR, axis: "right" }));
    drawLinePanel(svg.append("g"), { x: 0, y: 0, w: W, h: H },
      { series, scale, gridlines: true }, tip);
    return;
  }

  // Subplots — one region per metric (2nd panel's axis tinted orange).
  const panels = [
    toSeries(primary, { kind: style, color: LINE_COLOR, axis: "left" }),
    toSeries(secondary, { kind: "line", color: SECOND_COLOR, axis: "left" }),
  ];
  const regions = layoutRegions(tiling, panels.length, W, H);
  panels.forEach((s, i) => {
    drawLinePanel(svg.append("g"), regions[i],
      { series: [s], scale, gridlines: true,
        leftAxisColor: i === 1 ? SECOND_COLOR : null }, tip);
  });
}

// Region rectangles for the subplot tilings. Each region is the FULL
// svg-pixel box for that panel; drawLinePanel applies its own inner margins.
function layoutRegions(tiling, n, W, H) {
  if (tiling === "vertical") {
    const h = H / n;
    return d3.range(n).map(i => ({ x: 0, y: i * h, w: W, h }));
  }
  if (tiling === "horizontal") {
    const w = W / n;
    return d3.range(n).map(i => ({ x: i * w, y: 0, w, h: H }));
  }
  // grid (near-square): cols = ceil(sqrt(n)), rows = ceil(n/cols).
  const cols = Math.ceil(Math.sqrt(n));
  const rows = Math.ceil(n / cols);
  const w = W / cols, h = H / rows;
  return d3.range(n).map(i => ({
    x: (i % cols) * w, y: Math.floor(i / cols) * h, w, h,
  }));
}