/* ============================================================
   Charts — LineChart (53-week trend) + USMap (choropleth/bubbles)
   Bespoke SVG, brand-token styled. Exported to window.
   ============================================================ */

/* week index (0-based) → approximate month label position */
const MONTH_AT = [
  ["Jan", 0], ["Feb", 4], ["Mar", 8], ["Apr", 13], ["May", 17], ["Jun", 22],
  ["Jul", 26], ["Aug", 30], ["Sep", 35], ["Oct", 39], ["Nov", 43], ["Dec", 48]
];

function LineChart({ series, currentWeek, formatY, weeks = 53, height = 420, mobileWindow = true }) {
  const wrapRef = useRef(null);
  const svgRef = useRef(null);
  const [hover, setHover] = useState(null);          // week index
  const [off, setOff] = useState({});                 // hidden series keys
  const [w, setW] = useState(1000);
  useLayoutEffect(() => {
    function measure() { if (wrapRef.current) setW(wrapRef.current.clientWidth); }
    measure();
    const ro = new ResizeObserver(measure);
    if (wrapRef.current) ro.observe(wrapRef.current);
    return () => ro.disconnect();
  }, []);

  const H = height, mL = 64, mR = 22, mT = 18, mB = 40;
  const plotW = w - mL - mR, plotH = H - mT - mB;

  // On a narrow (mobile) viewport the full 53-week line is unreadable, so we
  // zoom to a focused window: the prior 4 weeks + the week ahead.
  const mobile = mobileWindow && w < 560 && currentWeek != null && currentWeek > 0;
  const i0 = mobile ? Math.max(0, currentWeek - 5) : 0;
  const i1 = mobile ? Math.min(weeks - 1, currentWeek) : weeks - 1;
  const iSpan = (i1 - i0) || 1;

  // y-domain from visible series, restricted to the visible week window
  let max = -Infinity, min = Infinity;
  series.forEach(s => {
    if (off[s.key]) return;
    for (let i = i0; i <= i1; i++) { const v = s.data[i]; if (v == null) continue; if (v > max) max = v; if (v < min) min = v; }
  });
  if (max === -Infinity) { max = 1; min = 0; }
  const pad = (max - min) * 0.14 || max * 0.1;
  let lo = Math.max(0, min - pad), hi = max + pad;
  const step = Math.pow(10, Math.floor(Math.log10(hi - lo || 1)));
  lo = Math.floor(lo / step) * step; hi = Math.ceil(hi / step) * step;

  const X = i => mL + ((i - i0) / iSpan) * plotW;
  const Y = v => mT + plotH - ((v - lo) / (hi - lo)) * plotH;

  const ticks = 5;
  const yTicks = [];
  for (let t = 0; t <= ticks; t++) yTicks.push(lo + (hi - lo) * t / ticks);

  function pathFor(s) {
    let d = "", started = false;
    for (let i = i0; i <= i1; i++) {
      const v = s.data[i];
      if (v == null) { continue; }
      d += (started ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " ";
      started = true;
    }
    return d.trim();
  }

  function onMove(e) {
    const rect = svgRef.current.getBoundingClientRect();
    const scale = w / rect.width;
    const x = (e.clientX - rect.left) * scale;
    let i = Math.round(((x - mL) / plotW) * iSpan) + i0;
    i = Math.max(i0, Math.min(i1, i));
    setHover(i);
  }

  // tooltip content
  let tip = null;
  if (hover != null) {
    const a26 = (series.find(s => s.key === "y2026") || {}).data;
    const a25 = (series.find(s => s.key === "y2025") || {}).data;
    const aPlan = (series.find(s => s.key === "plan") || {}).data;
    const v26 = a26 && a26[hover], v25 = a25 && a25[hover], vPlan = aPlan && aPlan[hover];
    tip = { i: hover, rows: series.filter(s => !off[s.key]).map(s => ({ s, v: s.data[hover] })) };
    if (v26 != null && v25 != null && !off.y2026 && !off.y2025) {
      const d = v26 - v25; tip.vsYear = { d, pct: v25 ? d / v25 * 100 : 0 };
    }
    if (v26 != null && vPlan != null && !off.y2026 && !off.plan) {
      const d = v26 - vPlan; tip.vsPlan = { d, pct: vPlan ? d / vPlan * 100 : 0 };
    }
  }

  // tooltip pixel position
  let tipStyle = {};
  if (tip) {
    const px = (X(tip.i) / w) * (wrapRef.current ? wrapRef.current.clientWidth : w);
    const flip = (tip.i - i0) > iSpan * 0.62;
    tipStyle = { left: px + "px", top: "8px", transform: `translate(${flip ? "-100%" : "0"}, 0)` };
  }

  return (
    <div className="lc-wrap" ref={wrapRef}>
      <svg ref={svgRef} className="lc-svg" viewBox={`0 0 ${w} ${H}`} width="100%" height={H}
        onMouseMove={onMove} onMouseLeave={() => setHover(null)} role="img"
        aria-label="Weekly trend line chart">
        {/* gridlines + y labels */}
        {yTicks.map((v, i) => (
          <g key={i}>
            <line x1={mL} x2={mL + plotW} y1={Y(v)} y2={Y(v)} className="lc-grid" />
            <text x={mL - 10} y={Y(v) + 4} className="lc-ylab" textAnchor="end">{formatY(v, true)}</text>
          </g>
        ))}
        {/* x labels: months (full view) or week numbers (mobile focus view) */}
        {mobile
          ? Array.from({ length: i1 - i0 + 1 }, (_, k) => i0 + k).map(idx => (
              <text key={idx} x={X(idx)} y={mT + plotH + 24} className="lc-xlab" textAnchor="middle">W{idx + 1}</text>
            ))
          : MONTH_AT.map(([m, idx]) => (
              <text key={m} x={X(idx)} y={mT + plotH + 24} className="lc-xlab" textAnchor="middle">{m}</text>
            ))
        }
        {/* current-week marker */}
        {currentWeek != null && currentWeek < weeks && (
          <g>
            <line x1={X(currentWeek - 1)} x2={X(currentWeek - 1)} y1={mT} y2={mT + plotH} className="lc-now" />
            <text x={X(currentWeek - 1)} y={mT - 5} className="lc-now-lab" textAnchor="middle">Now · W{currentWeek}</text>
          </g>
        )}
        <line x1={mL} x2={mL + plotW} y1={mT + plotH} y2={mT + plotH} className="lc-baseline" />
        {/* hover guide */}
        {hover != null && <line x1={X(hover)} x2={X(hover)} y1={mT} y2={mT + plotH} className="lc-guide" />}
        {/* series */}
        {series.map(s => off[s.key] ? null : (
          <path key={s.key} d={pathFor(s)}
            className={s.type === "plan" ? "lc-line lc-plan" : "lc-line"}
            stroke={s.color} />
        ))}
        {/* dots for actual current year only */}
        {series.filter(s => s.key === "y2026" && !off[s.key]).map(s =>
          s.data.map((v, i) => (v == null || i < i0 || i > i1) ? null : (
            <circle key={i} cx={X(i)} cy={Y(v)} r="2.6" fill={s.color} className="lc-dot" />
          ))
        )}
        {/* focus dots */}
        {hover != null && series.map(s => {
          if (off[s.key]) return null;
          const v = s.data[hover]; if (v == null) return null;
          return <circle key={s.key} cx={X(hover)} cy={Y(v)} r="5" fill={s.color} className="lc-focus" />;
        })}
      </svg>

      {/* legend (toggleable) */}
      <div className="lc-legend">
        {series.map(s => (
          <button key={s.key} type="button"
            className={"lc-leg" + (off[s.key] ? " off" : "")}
            onClick={() => setOff(o => ({ ...o, [s.key]: !o[s.key] }))}>
            <span className={"lc-sw" + (s.type === "plan" ? " dash" : "")}
              style={s.type === "plan" ? { borderTopColor: s.color } : { background: s.color }}></span>
            <span className="lc-leg-nm">{s.name}</span>
          </button>
        ))}
      </div>

      {/* tooltip */}
      {tip && (
        <div className="lc-tip" style={tipStyle}>
          <div className="lc-tip-h">Week {tip.i + 1} · {MM.weekDates(tip.i + 1)}{tip.i + 1 >= currentWeek ? " · plan" : ""}</div>
          {tip.rows.map(({ s, v }) => (
            <div className="lc-tip-row" key={s.key}>
              <span className={"lc-tip-d" + (s.type === "plan" ? " dash" : "")}
                style={s.type === "plan" ? { borderTopColor: s.color } : { background: s.color }}></span>
              <span className="lc-tip-nm">{s.name}</span>
              <span className="lc-tip-v">{v == null ? "—" : formatY(v)}</span>
            </div>
          ))}
          {(tip.vsYear || tip.vsPlan) && <div className="lc-tip-sep"></div>}
          {tip.vsYear && (
            <div className="lc-tip-row cmp">
              <span className="lc-tip-nm">’26 vs ’25</span>
              <span className={"lc-tip-v " + (tip.vsYear.d >= 0 ? "up" : "down")}>
                {MM.fmt.money(tip.vsYear.d, { signed: true })} ({MM.fmt.pct(tip.vsYear.pct)})
              </span>
            </div>
          )}
          {tip.vsPlan && (
            <div className="lc-tip-row cmp">
              <span className="lc-tip-nm">’26 vs plan</span>
              <span className={"lc-tip-v " + (tip.vsPlan.d >= 0 ? "up" : "down")}>
                {MM.fmt.money(tip.vsPlan.d, { signed: true })} ({MM.fmt.pct(tip.vsPlan.pct)})
              </span>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ============================================================
   USMap — choropleth (+ optional bubbles) over the brand US SVG
   metric drives color; diverging metrics center at 0.
   ============================================================ */
const SEQ_RAMP = ["#fce4ee", "#f7bcd6", "#f088b3", "#e54f90", "#cf2d72", "#a81a57"];
const DIV_NEG = ["#c0341d", "#d9745f", "#ecb3a6"];   // opportunity → pale
const DIV_POS = ["#bfe0cf", "#5cae84", "#1f8a5b"];   // pale → great

/* ---- US Albers projection (lat/lng → SVG coords) -------------------------
   The base state SVG is an Albers-style US map. We project each owner's real
   {lat,lng} through a standard US Albers equal-area conic, then snap the result
   into THIS svg's coordinate space with an affine transform that's least-squares
   fitted to the measured state centroids. Fitting to the actual geometry means
   the placement self-calibrates to the exact base map / viewBox, with no
   hand-tuned constants. Alaska & Hawaii sit in a separate inset, so owners there
   (and any owner without coordinates) fall back to state-centroid jitter. */
// Excluded from the continental Albers projection. AK/HI have their own inset
// paths, so they fall back to state-centroid jitter; PR has no geometry on this
// base map at all, so its owners drop to the "unmapped" count.
const NON_ALBERS_STATES = { AK: 1, HI: 1, PR: 1 };
const ALBERS_PARALLELS = [29.5, 45.5];          // standard US parallels
const ALBERS_CENTER = [-96, 37.5];              // central meridian / origin lat

function albersRaw(lat, lng) {
  const rad = Math.PI / 180;
  const p0 = ALBERS_PARALLELS[0] * rad, p1 = ALBERS_PARALLELS[1] * rad;
  const lng0 = ALBERS_CENTER[0] * rad, lat0 = ALBERS_CENTER[1] * rad;
  const n = 0.5 * (Math.sin(p0) + Math.sin(p1));
  const C = Math.cos(p0) ** 2 + 2 * n * Math.sin(p0);
  const rho0 = Math.sqrt(C - 2 * n * Math.sin(lat0)) / n;
  const theta = n * (lng * rad - lng0);
  const rho = Math.sqrt(C - 2 * n * Math.sin(lat * rad)) / n;
  return [rho * Math.sin(theta), rho0 - rho * Math.cos(theta)];
}

/* Each state's geographic bounding-box CENTER (lat,lng). These pair with the
   state's measured SVG getBBox center (also a bbox center, so the correspondence
   is consistent), giving a tight affine fit — residuals are ~3px on this map. */
const STATE_CENTROIDS = {
  AL: [32.615, -86.68], AR: [34.75, -92.13], AZ: [34.165, -111.935], CA: [37.27, -119.27],
  CO: [38.995, -105.55], CT: [41.515, -72.76], DC: [38.895, -77.015], DE: [39.145, -75.42],
  FL: [27.76, -83.83], GA: [32.68, -83.225], IA: [41.94, -93.39], ID: [45.495, -114.14],
  IL: [39.74, -89.265], IN: [39.765, -86.44], KS: [38.495, -98.32], KY: [37.825, -85.765],
  LA: [30.975, -91.43], MA: [42.065, -71.72], MD: [38.815, -77.27], ME: [45.26, -69.015],
  MI: [45.005, -86.415], MN: [46.44, -93.365], MO: [38.3, -92.435], MS: [32.585, -89.88],
  MT: [46.68, -110.045], NC: [35.215, -79.89], ND: [47.47, -100.3], NE: [41.5, -99.68],
  NH: [44.005, -71.585], NJ: [40.145, -74.725], NM: [34.165, -106.025], NV: [38.5, -117.025],
  NY: [42.755, -75.81], OH: [40.19, -82.67], OK: [35.31, -98.715], OR: [44.14, -120.515],
  PA: [40.995, -77.605], RI: [41.585, -71.515], SC: [33.625, -80.945], SD: [44.21, -100.25],
  TN: [35.83, -85.98], TX: [31.17, -100.08], UT: [39.495, -111.545], VA: [38.005, -79.46],
  VT: [43.875, -72.45], WA: [47.27, -120.885], WI: [44.9, -89.85], WV: [38.92, -80.18],
  WY: [43.0, -107.555],
};

/* Solve a 3x3 linear system (Gaussian elimination w/ partial pivoting). */
function solve3(A, b) {
  const M = A.map((row, i) => row.concat(b[i]));
  for (let c = 0; c < 3; c++) {
    let p = c;
    for (let r = c + 1; r < 3; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
    if (Math.abs(M[p][c]) < 1e-12) return null;
    [M[c], M[p]] = [M[p], M[c]];
    for (let r = 0; r < 3; r++) {
      if (r === c) continue;
      const f = M[r][c] / M[c][c];
      for (let k = c; k < 4; k++) M[r][k] -= f * M[c][k];
    }
  }
  return [M[0][3] / M[0][0], M[1][3] / M[1][1], M[2][3] / M[2][2]];
}

/* Build a projector lat/lng → {x,y} by least-squares fitting an affine map
   from raw-Albers planar coords to the measured SVG centroids. Returns null
   if too few continental states are present to fit reliably. */
function makeAlbersProjector(cent) {
  const ATA = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
  const bx = [0, 0, 0], by = [0, 0, 0];
  let n = 0;
  for (const st in STATE_CENTROIDS) {
    const c = cent[st];
    if (!c || NON_ALBERS_STATES[st]) continue;
    const [lat, lng] = STATE_CENTROIDS[st];
    const [px, py] = albersRaw(lat, lng);
    const basis = [px, py, 1];
    for (let i = 0; i < 3; i++) {
      for (let j = 0; j < 3; j++) ATA[i][j] += basis[i] * basis[j];
      bx[i] += basis[i] * c.cx;
      by[i] += basis[i] * c.cy;
    }
    n++;
  }
  if (n < 4) return null;
  const cx = solve3(ATA, bx), cy = solve3(ATA, by);
  if (!cx || !cy) return null;
  return function project(lat, lng) {
    const [px, py] = albersRaw(lat, lng);
    return { x: cx[0] * px + cx[1] * py + cx[2], y: cy[0] * px + cy[1] * py + cy[2] };
  };
}

/* Location-bubble map: one dot per franchise owner. Size = revenue,
   color = the selected metric. States are a faint base for geography. */
function USMap({ metric, range, fbcIds, onPickState, onPickOwner }) {
  const baseRef = useRef(null);
  const [cent, setCent] = useState(null);
  const [tip, setTip] = useState(null);
  const [hoverId, setHoverId] = useState(null);
  const VIEWBOX = "174 100 959 593";
  const CW = MM.config.currentWeek, ci = CW - 1;

  // inject base states once, measure centroids, wire state-click filtering
  useLayoutEffect(() => {
    const mount = baseRef.current;
    mount.innerHTML = window.US_STATES_SVG;
    const svg = mount.querySelector("#us-map-svg");
    svg.setAttribute("width", "100%"); svg.removeAttribute("height");
    const cents = {};
    Array.prototype.forEach.call(svg.querySelectorAll("[id]"), el => {
      const id = el.getAttribute("id");
      if (!window.US_STATE_NAMES[id]) return;
      el.setAttribute("class", "us-state-base");
      const b = el.getBBox();
      cents[id] = { cx: b.x + b.width / 2, cy: b.y + b.height / 2, w: b.width, h: b.height };
      el.onclick = () => onPickState && onPickState(id);
    });
    setCent(cents);
  }, []);

  // revenue window drives dot size (and revenue/growth color)
  function win(o) {
    if (range === "week") return { cur: o.weekly.rev2026[ci] || 0, prior: o.weekly.rev2025[ci] || 0 };
    if (range === "rolling") { let c = 0, p = 0; for (let i = Math.max(0, ci - 3); i <= ci; i++) { c += o.weekly.rev2026[i] || 0; p += o.weekly.rev2025[i] || 0; } return { cur: c, prior: p }; }
    return { cur: o.ytd2026, prior: o.ytd2025 };
  }
  function colorVal(o) {
    let v;
    switch (metric) {
      case "revenue": v = win(o).cur; break;
      case "growth": { const w = win(o); v = w.prior ? (w.cur - w.prior) / w.prior * 100 : 0; break; }
      case "conversion": v = o.conv2026 - o.conv2025; break;
      case "frequency": v = o.freq2025 ? (o.freq2026 - o.freq2025) / o.freq2025 * 100 : 0; break;
      case "retention": v = -(o.churn2026 - o.churn2025); break;
      default: v = 0;
    }
    // Guard against missing/non-numeric source data (e.g. an Excel '#VALUE!'):
    // a single NaN would otherwise propagate through cabs and blank every dot.
    return Number.isFinite(v) ? v : 0;
  }
  const isRamp = metric === "revenue";
  const isDiv = ["growth", "conversion", "frequency", "retention"].indexOf(metric) >= 0;
  const isCat = metric === "engagement" || metric === "nps";

  const pool = MM.ownersFor(fbcIds);
  const revs = pool.map(o => win(o).cur);
  const rMinV = Math.min(...revs, 0), rMaxV = Math.max(...revs, 1);
  const sq = v => Math.sqrt(Math.max(0, v));
  const radius = v => 3.5 + (sq(v) - sq(rMinV)) / ((sq(rMaxV) - sq(rMinV)) || 1) * 17;

  let cmin = 0, cmax = 1, cabs = 1;
  if (isRamp) { const cv = pool.map(colorVal); cmin = Math.min(...cv, 0); cmax = Math.max(...cv, 1); }
  if (isDiv) { cabs = Math.max(0.0001, ...pool.map(o => Math.abs(colorVal(o)))); }

  const cPace = MM.config.currentWeek / MM.config.weeks * ((MM.goals.engagement && MM.goals.engagement.targetYear) || 10);
  function colorOf(o) {
    if (metric === "engagement") { const c = o.contactsYtd; return c >= cPace ? "#1f8a5b" : c >= cPace * 0.6 ? "#c97b00" : "#c0341d"; }
    if (metric === "nps") { const r = o.npsRating; return r >= 9 ? "#1f8a5b" : r >= 7 ? "#c97b00" : "#c0341d"; }
    const v = colorVal(o);
    if (isRamp) { const t = (v - cmin) / (cmax - cmin || 1); return SEQ_RAMP[Math.min(SEQ_RAMP.length - 1, Math.floor(t * SEQ_RAMP.length))]; }
    const t = Math.min(1, Math.abs(v) / cabs); const ramp = v >= 0 ? DIV_POS : DIV_NEG;
    return ramp[Math.min(ramp.length - 1, Math.floor(t * ramp.length))];
  }
  function fmtColor(o) {
    const w = win(o);
    switch (metric) {
      case "revenue": return MM.fmt.money(w.cur) + " revenue";
      case "growth": return MM.fmt.pct(w.prior ? (w.cur - w.prior) / w.prior * 100 : 0) + " vs 2025";
      case "conversion": { const d = o.conv2026 - o.conv2025; return (d >= 0 ? "+" : "") + d.toFixed(1) + " pts conversion"; }
      case "frequency": { const d = o.freq2025 ? (o.freq2026 - o.freq2025) / o.freq2025 * 100 : 0; return MM.fmt.pct(d) + " cleans/customer YoY"; }
      case "retention": { const imp = -(o.churn2026 - o.churn2025); return (imp >= 0 ? "+" : "") + imp.toFixed(1) + " pts churn"; }
      case "engagement": return o.contactsYtd + " FranConnect contacts YTD";
      case "nps": return o.npsRating + "/10 · " + (o.npsRating >= 9 ? "Promoter" : o.npsRating >= 7 ? "Passive" : "Detractor");
      default: return "";
    }
  }

  // Albers projection for owners with real coords; state-centroid jitter is the
  // fallback when lat/lng is unavailable (or for inset states / projection gaps).
  const proj = cent ? makeAlbersProjector(cent) : null;
  function jit(s) { const x = Math.sin(s) * 43758.5453; return x - Math.floor(x); }
  function jitterPos(o) {  // deterministic jitter inside the state's bbox
    const c = cent[o.state] || (o.state === "DC" ? cent.MD : null);
    if (!c) return null;
    const n = parseInt(o.id.slice(1), 10) || 1;
    return { x: c.cx + (jit(n * 12.9898) - 0.5) * c.w * 0.52,
             y: c.cy + (jit(n * 78.233) - 0.5) * c.h * 0.52 };
  }

  const dots = [];
  let approxCount = 0, missingCount = 0;  // fallback-placed / unplaceable
  if (cent) {
    pool.forEach(o => {
      const hasGeo = typeof o.lat === "number" && typeof o.lng === "number";
      let pos = (hasGeo && proj && !NON_ALBERS_STATES[o.state]) ? proj(o.lat, o.lng) : null;
      if (!pos) { pos = jitterPos(o); if (pos) approxCount++; }  // fallback: state jitter
      if (!pos) { missingCount++; return; }                      // no coords & no centroid
      dots.push({ o, x: pos.x, y: pos.y, r: radius(win(o).cur), fill: colorOf(o) });
    });
    dots.sort((a, b) => b.r - a.r);  // bigger dots first so small sit on top
    if (missingCount) console.warn(`USMap: ${missingCount} owner(s) could not be placed (no coordinates or matching state).`);
  }

  const rangeLabel = range === "week" ? "Week " + CW : range === "rolling" ? "Rolling 4 wks" : "YTD";
  const DIV_LABELS = { growth: ["Below 2025", "Above 2025"], conversion: ["Declining YoY", "Improving YoY"], frequency: ["Declining YoY", "Improving YoY"], retention: ["Churn rising", "Churn falling"] };

  let leftLab, rightLab, swatches;
  if (isRamp) { leftLab = "Lower revenue"; rightLab = "Higher revenue"; swatches = SEQ_RAMP; }
  else if (isDiv) { const L = DIV_LABELS[metric]; leftLab = L[0]; rightLab = L[1]; swatches = DIV_NEG.slice().reverse().concat(DIV_POS); }
  const cats = metric === "engagement"
    ? [{ c: "#c0341d", l: "Behind pace" }, { c: "#c97b00", l: "Near pace" }, { c: "#1f8a5b", l: "On pace (10/yr)" }]
    : [{ c: "#c0341d", l: "Detractor" }, { c: "#c97b00", l: "Passive" }, { c: "#1f8a5b", l: "Promoter" }];

  function showTip(e, o) {
    const r = baseRef.current.getBoundingClientRect();
    setTip({ x: e.clientX - r.left, y: e.clientY - r.top, o });
  }

  return (
    <div className="map-wrap">
      <div className="map-stage">
        <div className="map-base" ref={baseRef}></div>
        <svg className="map-overlay" viewBox={VIEWBOX} preserveAspectRatio="xMidYMid meet">
          {dots.map(d => (
            <circle key={d.o.id} cx={d.x} cy={d.y} r={d.r} fill={d.fill}
              className={"loc-dot" + (hoverId === d.o.id ? " hot" : "")}
              onMouseMove={(e) => { setHoverId(d.o.id); showTip(e, d.o); }}
              onMouseLeave={() => { setHoverId(null); setTip(null); }}
              onClick={() => onPickOwner && onPickOwner(d.o)} />
          ))}
        </svg>
        {tip && (
          <div className="map-tip" style={{ left: tip.x, top: tip.y }}>
            <b>{tip.o.contact}</b>
            <span className="map-tip-n">{tip.o.city}, {tip.o.state} · {(MM.fbcs.find(f => f.id === tip.o.fbcId) || {}).name}</span>
            <span className="map-tip-v">{fmtColor(tip.o)}</span>
            <span className="map-tip-rev">{MM.fmt.money(win(tip.o).cur)} · {rangeLabel}</span>
          </div>
        )}
      </div>

      <div className="map-legends">
        <div className="map-legend">
          {isCat
            ? cats.map((s, i) => <span key={i} className="map-cat"><span className="map-sw" style={{ background: s.c }}></span>{s.l}</span>)
            : <><span className="map-leg-lab">{leftLab}</span>{swatches.map((c, i) => <span key={i} className="map-sw" style={{ background: c }}></span>)}<span className="map-leg-lab">{rightLab}</span></>}
        </div>
        <div className="map-size-legend">
          <span className="map-leg-lab">Dot size = revenue ({rangeLabel})</span>
          <span className="map-size-ex"><span className="szc" style={{ width: 8, height: 8 }}></span><span className="szc" style={{ width: 15, height: 15 }}></span><span className="szc" style={{ width: 23, height: 23 }}></span></span>
        </div>
        <div className="map-count">
          {dots.length} locations
          {approxCount > 0 && <span className="map-count-note"> · {approxCount} approx placement</span>}
          {missingCount > 0 && <span className="map-count-note"> · {missingCount} unmapped</span>}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { LineChart, USMap });
