← Files ClaraARCHIVED FILE

modules/scatter-bubble-analysis/assets/scatter-bubble-review-widget.html

26.6 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Scatter & Bubble Analysis</title>
  <style>
    :root {
      color-scheme: light;
      --paper: #f6f7f8;
      --surface: #ffffff;
      --surface-soft: #eef3f1;
      --ink: #25221e;
      --text: #4d5560;
      --muted: #69727d;
      --border: #d6dce2;
      --border-strong: #9aa5b1;
      --green: #6e8f72;
      --rust: #9b5d46;
      --blue: #4d6f91;
      --gold: #b08a3c;
      --slate: #747982;
      --danger: #8f4438;
      --radius: 8px;
      --content: 1260px;
    }
    * { box-sizing: border-box; }
    html, body { background: var(--paper); }
    body {
      margin: 0;
      color: var(--text);
      font-family: Aptos, "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
      font-size: 15px;
      line-height: 1.45;
      letter-spacing: 0;
      text-rendering: optimizeLegibility;
    }
    .app {
      min-height: 100vh;
      padding: clamp(18px, 3vw, 42px);
    }
    .shell {
      max-width: var(--content);
      margin: 0 auto;
    }
    .topbar {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 24px;
      align-items: start;
      padding-bottom: 24px;
      border-bottom: 1px solid var(--border-strong);
    }
    .eyebrow {
      margin: 0 0 8px;
      color: var(--muted);
      font-size: 11px;
      font-weight: 650;
      letter-spacing: .08em;
      text-transform: uppercase;
    }
    h1 {
      margin: 0;
      max-width: 920px;
      color: var(--ink);
      font-size: clamp(32px, 5vw, 62px);
      font-weight: 650;
      line-height: 1;
      letter-spacing: 0;
    }
    .subtitle {
      max-width: 820px;
      margin: 14px 0 0;
      color: var(--muted);
      font-size: 16px;
    }
    .status {
      display: inline-flex;
      align-items: center;
      min-height: 32px;
      border: 1px solid var(--border);
      border-radius: 999px;
      padding: 5px 12px;
      background: var(--surface);
      color: var(--ink);
      font-size: 12px;
      font-weight: 650;
      white-space: nowrap;
    }
    .metrics {
      display: grid;
      grid-template-columns: repeat(6, minmax(120px, 1fr));
      border-bottom: 1px solid var(--border);
      margin: 0 0 36px;
    }
    .metric {
      min-width: 0;
      padding: 18px 18px 20px 0;
    }
    .metric + .metric {
      border-left: 1px solid var(--border);
      padding-left: 18px;
    }
    .metric span {
      display: block;
      color: var(--muted);
      font-size: 12px;
      line-height: 1.2;
    }
    .metric strong {
      display: block;
      margin-top: 7px;
      color: var(--ink);
      font-size: clamp(20px, 2.1vw, 29px);
      font-weight: 650;
      line-height: 1.08;
      overflow-wrap: normal;
      word-break: normal;
      hyphens: none;
      font-variant-numeric: tabular-nums;
    }
    section {
      padding: 34px 0 42px;
      border-top: 1px solid var(--border);
    }
    .section-head {
      display: grid;
      grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
      gap: clamp(22px, 4vw, 56px);
      align-items: start;
      margin-bottom: 20px;
    }
    h2 {
      margin: 0;
      color: var(--ink);
      font-size: clamp(24px, 3vw, 36px);
      font-weight: 640;
      line-height: 1.08;
      letter-spacing: 0;
    }
    .section-head p {
      margin: 0;
      max-width: 760px;
      color: var(--muted);
      font-size: 15px;
    }
    .analysis-grid {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(260px, 330px);
      gap: 24px;
      align-items: stretch;
    }
    .plot-panel,
    .side-panel {
      min-width: 0;
      border-top: 1px solid var(--border-strong);
      padding-top: 14px;
    }
    .legacy-toolbar {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 12px;
      color: var(--muted);
      font-size: 13px;
    }
    .button-link {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-height: 34px;
      border: 1px solid var(--border-strong);
      border-radius: var(--radius);
      padding: 6px 11px;
      background: var(--surface);
      color: var(--ink);
      font-weight: 650;
      text-decoration: none;
      white-space: nowrap;
    }
    .legacy-frame-shell {
      overflow: hidden;
      border: 1px solid var(--border);
      border-radius: var(--radius);
      background: var(--surface);
    }
    .legacy-frame {
      display: block;
      width: 100%;
      height: min(68vh, 720px);
      min-height: 520px;
      border: 0;
      background: var(--surface);
    }
    .rank-list {
      display: grid;
      gap: 10px;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .rank-item {
      display: grid;
      grid-template-columns: auto minmax(0, 1fr);
      gap: 10px;
      padding-bottom: 10px;
      border-bottom: 1px solid var(--border);
    }
    .rank {
      width: 28px;
      color: var(--muted);
      font-variant-numeric: tabular-nums;
    }
    .rank-title {
      display: block;
      color: var(--ink);
      font-weight: 650;
      overflow-wrap: anywhere;
    }
    .rank-meta {
      display: block;
      margin-top: 2px;
      color: var(--muted);
      font-size: 12px;
    }
    .tables {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(280px, .72fr);
      gap: 24px;
      align-items: start;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      font-size: 13px;
    }
    th {
      padding: 10px 8px;
      border-bottom: 1px solid var(--border-strong);
      color: var(--muted);
      text-align: left;
      font-weight: 650;
    }
    td {
      padding: 10px 8px;
      border-bottom: 1px solid var(--border);
      vertical-align: top;
      color: var(--text);
    }
    th:nth-child(n+2),
    td:nth-child(n+2) {
      text-align: right;
      font-variant-numeric: tabular-nums;
    }
    td:first-child {
      color: var(--ink);
      font-weight: 620;
      text-align: left;
    }
    .artifact-list {
      display: grid;
      gap: 8px;
    }
    .artifact {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 12px;
      align-items: start;
      padding: 10px 0;
      border-bottom: 1px solid var(--border);
      color: var(--text);
      text-decoration: none;
    }
    .artifact:hover .artifact-title {
      text-decoration: underline;
      text-underline-offset: 3px;
    }
    .artifact-title {
      color: var(--ink);
      font-weight: 640;
      overflow-wrap: anywhere;
    }
    .artifact-path {
      display: block;
      margin-top: 2px;
      color: var(--muted);
      font-size: 12px;
      overflow-wrap: anywhere;
    }
    .tag {
      color: var(--muted);
      font-size: 12px;
      white-space: nowrap;
    }
    .notice {
      border-left: 3px solid var(--danger);
      padding-left: 14px;
      color: var(--muted);
      max-width: 860px;
    }
    .empty {
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 18px;
      background: var(--surface);
      color: var(--muted);
    }
    @media (max-width: 980px) {
      .topbar,
      .section-head,
      .analysis-grid,
      .tables {
        grid-template-columns: 1fr;
      }
      .metrics {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      .metric:nth-child(odd) {
        border-left: 0;
        padding-left: 0;
      }
    }
    @media (max-width: 640px) {
      .app { padding: 18px; }
      .metrics { grid-template-columns: 1fr; }
      .metric {
        border-left: 0 !important;
        padding-left: 0 !important;
      }
    }
  </style>
</head>
<body>
  <main class="app">
    <div class="shell">
      <header class="topbar">
        <div>
          <p class="eyebrow" id="eyebrow">Scatter & Bubble</p>
          <h1 id="page-title">Relationship analysis</h1>
          <p class="subtitle" id="subtitle">Loading run payload...</p>
        </div>
        <span class="status" id="status-pill">loading</span>
      </header>
      <div class="metrics" id="metrics"></div>
      <section id="relationship">
        <div class="section-head">
          <h2 id="plot-title">Legacy Bubble Chart</h2>
          <p id="plot-copy">The primary chart is the legacy Plotly HTML artifact generated by the vendored scatter and bubble charting code.</p>
        </div>
        <div class="analysis-grid">
          <div class="plot-panel">
            <div class="legacy-toolbar" id="legacy-chart-toolbar"></div>
            <div id="plot"></div>
          </div>
          <aside class="side-panel">
            <h2 id="rank-title" style="font-size: 20px; margin-bottom: 14px;">Largest dots</h2>
            <ol class="rank-list" id="rank-list"></ol>
          </aside>
        </div>
      </section>
      <section id="table-section">
        <div class="section-head">
          <h2 id="drivers-title">Drivers and Outputs</h2>
          <p id="drivers-copy">The table is the generated relationship summary. Output links point to chart, context, and report artifacts when this page is opened from a plugin run folder.</p>
        </div>
        <div class="tables">
          <div id="driver-table"></div>
          <div>
            <h2 id="artifacts-title" style="font-size: 20px; margin-bottom: 14px;">Artifacts</h2>
            <div class="artifact-list" id="artifact-list"></div>
          </div>
        </div>
      </section>
      <section id="caveats">
        <div class="section-head">
          <h2 id="run-status-title">Run Status</h2>
          <p id="run-status"></p>
        </div>
        <p class="notice" id="caveat"></p>
      </section>
    </div>
  </main>
  <script>
    const UI_COPY = {
      en: {
        eyebrow: "Scatter & Bubble",
        pageTitle: "Relationship analysis",
        plotTitle: "Legacy Bubble Chart",
        rankTitle: "Largest dots",
        driversTitle: "Drivers and Outputs",
        driversCopy: "The table is the generated relationship summary. Output links point to chart, context, and report artifacts when this page is opened from a plugin run folder.",
        artifactsTitle: "Artifacts",
        runStatusTitle: "Run Status",
        run: "Run",
        previewFixture: "preview fixture",
        ready: "ready",
        metrics: ["Top dot", "Top size", "X metric", "Y metric", "Dots", "Charts written"],
        plotCopy: "Legacy chart for {y} versus {x} across {count} relationship dots. Total size metric: {total}.",
        noRowsPayload: "No relationship rows were included in the payload.",
        noRows: "No relationship rows.",
        dot: "Dot",
        noArtifacts: "No output artifacts in payload.",
        artifact: "Artifact",
        chartKind: "chart",
        contextKind: "context",
        reportKind: "report",
        chartsStatus: "Charts written: {written} of {attempts}. Source artifacts: {sources}.",
        previewNotice: "This is the widget preview. Open the plugin-generated scatter_bubble_review.html from a run folder, or render through MCP, to see real data.",
        sourceNotice: "Use the chart and context artifacts for full source context.",
        plotAria: "Scatter bubble plot",
        statuses: { ready_for_review: "Ready for review", preview: "Preview", empty: "Empty" },
      },
      es: {
        eyebrow: "Dispersión y burbujas",
        pageTitle: "Análisis de relaciones",
        plotTitle: "Gráfico de burbujas",
        rankTitle: "Puntos principales",
        driversTitle: "Factores y salidas",
        driversCopy: "La tabla contiene el resumen de relaciones generado. Los enlaces llevan a los gráficos, el contexto y los informes cuando esta página se abre desde la carpeta de una ejecución.",
        artifactsTitle: "Artefactos",
        runStatusTitle: "Estado de la ejecución",
        run: "Ejecución",
        previewFixture: "vista previa",
        ready: "listo",
        metrics: ["Punto principal", "Tamaño principal", "Métrica X", "Métrica Y", "Puntos", "Gráficos generados"],
        plotCopy: "Gráfico de {y} frente a {x} para {count} puntos de relación. Métrica de tamaño total: {total}.",
        noRowsPayload: "Los datos no incluyen filas de relaciones.",
        noRows: "No hay filas de relaciones.",
        dot: "Punto",
        noArtifacts: "Los datos no incluyen artefactos de salida.",
        artifact: "Artefacto",
        chartKind: "gráfico",
        contextKind: "contexto",
        reportKind: "informe",
        chartsStatus: "Gráficos generados: {written} de {attempts}. Artefactos fuente: {sources}.",
        previewNotice: "Esta es la vista previa del widget. Abra scatter_bubble_review.html desde la carpeta de una ejecución o muéstrelo mediante MCP para consultar los datos reales.",
        sourceNotice: "Utilice los artefactos de gráficos y contexto para consultar todas las fuentes.",
        plotAria: "Gráfico de dispersión y burbujas",
        statuses: { ready_for_review: "Listo para revisión", preview: "Vista previa", empty: "Vacío" },
      },
    };

    const PREVIEW_PAYLOAD = {
      widget_type: "scatter_bubble_review",
      preview: true,
      review_payload: {
        run_id: "preview-no-run-payload",
        status: "preview",
        summary: {
          dot_dimension: "Model",
          x_metric: "Cogs",
          y_metric: "Salesamount",
          size_metric: "Units",
          top_dot: "Mountain-200",
          top_size_value: 3552,
          chart_count: 5,
          legacy_chart_written_count: 4,
          source_artifacts_status: "preview",
        },
        items: [
          driver("Mountain-200", 4646789, 8543618, 3552),
          driver("Road-250", 3128556, 5036093, 1903),
          driver("Road-150", 4023886, 6631322, 1552),
          driver("Touring-1000", 2118415, 3408004, 1255),
          driver("Hydration Pack", 12051, 32223, 706),
          artifact("bubble.html", "chart_artifact", "Legacy bubble"),
          artifact("scatter.html", "chart_artifact", "Legacy scatter"),
          artifact("scatter_bubble_results.xlsx", "report_artifact", "Results workbook"),
        ],
        item_count: 7,
      },
      final_artifacts: { caveats: ["Preview fixture. A plugin run page embeds the real review_payload.json."] },
    };

    function driver(title, x, y, size) {
      return {
        id: `driver-${title}`,
        item_type: "relationship_driver",
        title,
        output_path: "scatter_bubble_summary.csv",
        source_records: [{
          kind: "relationship_metrics",
          dot_dimension: "Model",
          x_metric: "Cogs",
          x_value: x,
          y_metric: "Salesamount",
          y_value: y,
          size_metric: "Units",
          size_value: size,
        }],
        data: { Model: title, Cogs: x, Salesamount: y, Units: size },
      };
    }

    function artifact(path, type, title) {
      return {
        id: `artifact-${path}`,
        item_type: type,
        title,
        output_path: path,
        data: { path, kind: type.replace("_artifact", ""), artifact_id: title },
      };
    }

    function payloadSource() {
      return window.openai?.toolOutput
        || window.openai?.structuredContent
        || window.__SCATTER_BUBBLE_PAYLOAD__
        || PREVIEW_PAYLOAD;
    }

    const payload = payloadSource();
    const review = payload.review_payload || PREVIEW_PAYLOAD.review_payload;
    const summary = review.summary || {};
    const allItems = Array.isArray(review.items) ? review.items : [];
    const driverItems = allItems.filter((item) => item.item_type === "relationship_driver");
    const artifactItems = allItems.filter((item) => item.item_type && item.item_type !== "relationship_driver");
    const colors = ["#6e8f72", "#9b5d46", "#4d6f91", "#b08a3c", "#747982", "#7d6f92"];

    function language() {
      const value = review.language || payload.run_intake?.language || "en";
      const code = String(value).trim().toLowerCase().replace("_", "-").split("-", 1)[0];
      return code === "es" ? "es" : "en";
    }

    function copy() {
      return UI_COPY[language()];
    }

    function interpolate(template, values) {
      return Object.entries(values).reduce(
        (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
        template,
      );
    }

    function renderStaticCopy() {
      const text = copy();
      document.documentElement.lang = language();
      document.title = text.pageTitle;
      document.getElementById("eyebrow").textContent = text.eyebrow;
      document.getElementById("page-title").textContent = text.pageTitle;
      document.getElementById("plot-title").textContent = text.plotTitle;
      document.getElementById("rank-title").textContent = text.rankTitle;
      document.getElementById("drivers-title").textContent = text.driversTitle;
      document.getElementById("drivers-copy").textContent = text.driversCopy;
      document.getElementById("artifacts-title").textContent = text.artifactsTitle;
      document.getElementById("run-status-title").textContent = text.runStatusTitle;
    }

    function esc(value) {
      return String(value ?? "").replace(/[&<>"']/g, (char) => ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      })[char]);
    }

    function num(value) {
      const parsed = Number(value);
      return Number.isFinite(parsed) ? parsed : 0;
    }

    function format(value, digits = 0) {
      const parsed = Number(value);
      if (!Number.isFinite(parsed)) return value == null || value === "" ? "n/a" : String(value);
      return new Intl.NumberFormat(language() === "es" ? "es-ES" : undefined, { maximumFractionDigits: digits }).format(parsed);
    }

    function compact(value) {
      const parsed = Number(value);
      if (!Number.isFinite(parsed)) return value == null ? "n/a" : String(value);
      if (Math.abs(parsed) >= 1000000) return `${format(parsed / 1000000, 1)}m`;
      if (Math.abs(parsed) >= 1000) return `${format(parsed / 1000, 0)}k`;
      return format(parsed, 0);
    }

    function metric(label, value) {
      return `<article class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></article>`;
    }

    function relationshipRows() {
      const xMetric = summary.x_metric || "x";
      const yMetric = summary.y_metric || "y";
      const sizeMetric = summary.size_metric || "size";
      return driverItems.map((item, index) => {
        const sourceRecord = (item.source_records || []).find((entry) => entry.kind === "relationship_metrics") || {};
        return {
          title: item.title || `${copy().dot} ${index + 1}`,
          xMetric: sourceRecord.x_metric || xMetric,
          yMetric: sourceRecord.y_metric || yMetric,
          sizeMetric: sourceRecord.size_metric || sizeMetric,
          x: num(sourceRecord.x_value ?? item.data?.[xMetric]),
          y: num(sourceRecord.y_value ?? item.data?.[yMetric]),
          size: num(sourceRecord.size_value ?? item.data?.[sizeMetric]),
          output: item.output_path || "",
        };
      }).filter((row) => row.x || row.y || row.size);
    }

    function renderHeader(rows) {
      const text = copy();
      const mode = payload.preview
        ? text.previewFixture
        : (text.statuses[review.status] || review.status || text.ready);
      document.getElementById("status-pill").textContent = mode;
      document.getElementById("subtitle").textContent = `${text.run} ${review.run_id || "n/a"}`;
      const totals = rows.reduce((acc, row) => {
        acc.x += row.x;
        acc.y += row.y;
        acc.size += row.size;
        return acc;
      }, { x: 0, y: 0, size: 0 });
      document.getElementById("metrics").innerHTML = [
        metric(text.metrics[0], summary.top_dot || rows[0]?.title || "n/a"),
        metric(text.metrics[1], format(summary.top_size_value ?? rows[0]?.size)),
        metric(text.metrics[2], summary.x_metric || rows[0]?.xMetric || "n/a"),
        metric(text.metrics[3], summary.y_metric || rows[0]?.yMetric || "n/a"),
        metric(text.metrics[4], rows.length),
        metric(text.metrics[5], summary.legacy_chart_written_count ?? "n/a"),
      ].join("");
      document.getElementById("plot-copy").textContent = interpolate(text.plotCopy, {
        y: summary.y_metric || rows[0]?.yMetric || "Y",
        x: summary.x_metric || rows[0]?.xMetric || "X",
        count: rows.length,
        total: format(totals.size),
      });
    }

    function scale(domainMin, domainMax, rangeMin, rangeMax, value) {
      if (domainMax <= domainMin) return (rangeMin + rangeMax) / 2;
      return rangeMin + ((value - domainMin) / (domainMax - domainMin)) * (rangeMax - rangeMin);
    }

    function renderPlot(rows) {
      if (!rows.length) {
        document.getElementById("plot").innerHTML = `<div class="empty">${esc(copy().noRowsPayload)}</div>`;
        return;
      }
      const width = 920;
      const height = 560;
      const margin = { left: 76, right: 34, top: 30, bottom: 70 };
      const plotW = width - margin.left - margin.right;
      const plotH = height - margin.top - margin.bottom;
      const minX = Math.min(0, ...rows.map((row) => row.x));
      const maxX = Math.max(...rows.map((row) => row.x)) * 1.08;
      const minY = Math.min(0, ...rows.map((row) => row.y));
      const maxY = Math.max(...rows.map((row) => row.y)) * 1.08;
      const maxSize = Math.max(...rows.map((row) => row.size), 1);
      const ticks = [0, 0.25, 0.5, 0.75, 1];
      const grid = ticks.map((tick) => {
        const x = margin.left + tick * plotW;
        const y = margin.top + (1 - tick) * plotH;
        return `
          <line class="grid" x1="${x}" y1="${margin.top}" x2="${x}" y2="${margin.top + plotH}"></line>
          <line class="grid" x1="${margin.left}" y1="${y}" x2="${margin.left + plotW}" y2="${y}"></line>
          <text class="tick" x="${x}" y="${height - 34}" text-anchor="middle">${esc(compact(minX + tick * (maxX - minX)))}</text>
          <text class="tick" x="${margin.left - 12}" y="${y + 4}" text-anchor="end">${esc(compact(minY + tick * (maxY - minY)))}</text>
        `;
      }).join("");
      const dots = rows.map((row, index) => {
        const x = scale(minX, maxX, margin.left, margin.left + plotW, row.x);
        const y = scale(minY, maxY, margin.top + plotH, margin.top, row.y);
        const radius = 7 + Math.sqrt(row.size / maxSize) * 25;
        const color = colors[index % colors.length];
        const labelY = Math.max(14, y - radius - 8);
        return `
          <circle class="dot" cx="${x}" cy="${y}" r="${radius}" fill="${color}">
            <title>${esc(row.title)} | ${esc(row.xMetric)} ${format(row.x)} | ${esc(row.yMetric)} ${format(row.y)} | ${esc(row.sizeMetric)} ${format(row.size)}</title>
          </circle>
          <text class="dot-label" x="${x}" y="${labelY}" text-anchor="middle">${esc(row.title)}</text>
        `;
      }).join("");
      document.getElementById("plot").innerHTML = `
        <svg class="plot-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(copy().plotAria)}">
          ${grid}
          <line class="axis" x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}"></line>
          <line class="axis" x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotH}"></line>
          ${dots}
          <text class="axis-label" x="${margin.left + plotW / 2}" y="${height - 12}" text-anchor="middle">${esc(rows[0].xMetric)}</text>
          <text class="axis-label" transform="translate(18 ${margin.top + plotH / 2}) rotate(-90)" text-anchor="middle">${esc(rows[0].yMetric)}</text>
        </svg>
      `;
    }

    function renderRanks(rows) {
      const ranked = [...rows].sort((a, b) => b.size - a.size).slice(0, 8);
      document.getElementById("rank-list").innerHTML = ranked.map((row, index) => `
        <li class="rank-item">
          <span class="rank">${index + 1}</span>
          <span>
            <span class="rank-title">${esc(row.title)}</span>
            <span class="rank-meta">${esc(row.sizeMetric)} ${format(row.size)} | ${esc(row.yMetric)} ${compact(row.y)}</span>
          </span>
        </li>
      `).join("");
    }

    function renderTable(rows) {
      if (!rows.length) {
        document.getElementById("driver-table").innerHTML = `<div class="empty">${esc(copy().noRows)}</div>`;
        return;
      }
      document.getElementById("driver-table").innerHTML = `
        <table>
          <thead>
            <tr>
              <th>${esc(copy().dot)}</th>
              <th>${esc(rows[0].xMetric)}</th>
              <th>${esc(rows[0].yMetric)}</th>
              <th>${esc(rows[0].sizeMetric)}</th>
            </tr>
          </thead>
          <tbody>
            ${rows.map((row) => `
              <tr>
                <td>${esc(row.title)}</td>
                <td>${format(row.x)}</td>
                <td>${format(row.y)}</td>
                <td>${format(row.size)}</td>
              </tr>
            `).join("")}
          </tbody>
        </table>
      `;
    }

    function artifactKind(item) {
      const kind = String(item.item_type || "artifact").replace("_artifact", "").replace("_", " ");
      return { chart: copy().chartKind, context: copy().contextKind, report: copy().reportKind }[kind] || kind;
    }

    function renderArtifacts() {
      const artifacts = artifactItems.slice(0, 16);
      if (!artifacts.length) {
        document.getElementById("artifact-list").innerHTML = `<div class="empty">${esc(copy().noArtifacts)}</div>`;
        return;
      }
      document.getElementById("artifact-list").innerHTML = artifacts.map((item) => {
        const path = item.output_path || item.data?.path || "";
        const href = path ? ` href="${esc(path)}"` : "";
        return `
          <a class="artifact"${href}>
            <span>
              <span class="artifact-title">${esc(item.title || path || copy().artifact)}</span>
              <span class="artifact-path">${esc(path)}</span>
            </span>
            <span class="tag">${esc(artifactKind(item))}</span>
          </a>
        `;
      }).join("");
    }

    function renderStatus() {
      const caveats = payload.final_artifacts?.caveats || [];
      const text = copy();
      const charts = summary.legacy_chart_written_count ?? "n/a";
      const attempts = summary.legacy_chart_attempt_count ?? "n/a";
      document.getElementById("run-status").textContent = interpolate(text.chartsStatus, {
        written: charts,
        attempts,
        sources: summary.source_artifacts_status || "n/a",
      });
      document.getElementById("caveat").textContent = caveats[0] || (
        payload.preview
          ? text.previewNotice
          : text.sourceNotice
      );
    }

    const rows = relationshipRows();
    renderStaticCopy();
    renderHeader(rows);
    renderPlot(rows);
    renderRanks(rows);
    renderTable(rows);
    renderArtifacts();
    renderStatus();
  </script>
</body>
</html>

SHA-256: 3b60aacf1075f72db82121c69a67101cd65514f6045e73750dc64d929721c252