← Spreadsheet results

Spreadsheet

Ornith 1.5 35B A3B · full

Initial view of Spreadsheet

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 47,704 bytes · SHA-256 e32ce0879ad7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spreadsheet</title>
<style>
  :root {
    --grid-border: #d9d9e3;
    --header-bg: #f8f9fb;
    --header-hover: #eef1f6;
    --header-active: #dce7ff;
    --sel-border: #1a73e8;
    --sel-fill: #dce9ff;
    --text: #202124;
    --error: #d93025;
    --formula-bg: #fff;
    --toolbar-bg: #f8f9fb;
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0; padding: 0; height: 100%;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    color: var(--text);
    background: #fff;
    font-size: 13px;
  }
  #app { display: flex; flex-direction: column; height: 100%; }

  /* ---------- Toolbar ---------- */
  #toolbar {
    display: flex; align-items: center; gap: 6px;
    padding: 6px 10px;
    background: var(--toolbar-bg);
    border-bottom: 1px solid var(--grid-border);
    flex: 0 0 auto;
  }
  #toolbar .title { font-weight: 600; margin-right: 12px; color: #444; }
  .tb-btn {
    border: 1px solid transparent;
    background: transparent;
    padding: 4px 9px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 12px;
    color: var(--text);
  }
  .tb-btn:hover { background: #eaeef5; border-color: var(--grid-border); }
  .tb-btn:disabled { opacity: .4; cursor: default; background: none; border-color: transparent; }
  .tb-sep { width: 1px; height: 18px; background: var(--grid-border); margin: 0 3px; }

  /* ---------- Formula bar ---------- */
  #formula-bar {
    display: flex; align-items: center;
    padding: 5px 8px;
    border-bottom: 1px solid var(--grid-border);
    background: #fff;
    flex: 0 0 auto;
    gap: 8px;
  }
  #cell-ref {
    width: 64px;
    text-align: center;
    border: 1px solid var(--grid-border);
    border-radius: 3px;
    padding: 3px 0;
    font-weight: 600;
    font-size: 12px;
    color: #444;
    flex: 0 0 auto;
    background: #fbfbfd;
  }
  #formula-icon { color: #888; font-style: italic; font-weight: 700; }
  #formula-input {
    flex: 1 1 auto;
    border: 1px solid var(--grid-border);
    border-radius: 3px;
    padding: 4px 6px;
    font-size: 13px;
    font-family: inherit;
    outline: none;
  }
  #formula-input:focus { border-color: var(--sel-border); box-shadow: 0 0 0 1px var(--sel-border); }

  /* ---------- Grid container ---------- */
  #grid-wrap {
    flex: 1 1 auto;
    overflow: auto;
    position: relative;
    background: #fff;
  }
  table#grid {
    border-collapse: collapse;
    table-layout: fixed;
    user-select: none;
    min-width: 100%;
  }
  table#grid th, table#grid td {
    border-right: 1px solid var(--grid-border);
    border-bottom: 1px solid var(--grid-border);
    background: #fff;
  }
  table#grid thead th {
    background: var(--header-bg);
    font-weight: 600;
    text-align: center;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    position: sticky;
    z-index: 3;
  }
  /* top-left corner cell */
  table#grid thead th.corner {
    left: 0; z-index: 5;
  }
  /* row header sticky to left */
  table#grid tbody th.row-head {
    background: var(--header-bg);
    font-weight: 600;
    text-align: center;
    width: 48px;
    left: 0;
    z-index: 2;
    position: sticky;
  }
  table#grid td.cell {
    padding: 0 6px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    cursor: cell;
    height: 24px;
    font-size: 13px;
    line-height: 24px;
  }
  table#grid td.cell.number { text-align: right; }
  table#grid td.cell.error { color: var(--error); font-style: italic; }
  table#grid td.cell.selected {
    outline: 2px solid var(--sel-border);
    outline-offset: -2px;
    background: var(--sel-fill);
    z-index: 1;
  }
  table#grid td.cell.editing {
    padding: 0;
    outline: 2px solid var(--sel-border);
    outline-offset: -2px;
  }
  /* ---------- Context menu ---------- */
  #context-menu {
    position: fixed;
    background: #fff;
    border: 1px solid var(--grid-border);
    border-radius: 5px;
    box-shadow: 0 4px 16px rgba(0,0,0,.18);
    padding: 4px 0;
    z-index: 100;
    display: none;
    min-width: 160px;
  }
  #context-menu .cm-item {
    padding: 6px 14px;
    cursor: pointer;
    font-size: 13px;
  }
  #context-menu .cm-item:hover { background: #eef1f6; }
  #context-menu .cm-sep { height: 1px; background: var(--grid-border); margin: 3px 0; }
  #status {
    padding: 3px 10px;
    background: var(--toolbar-bg);
    border-top: 1px solid var(--grid-border);
    font-size: 11px;
    color: #666;
    flex: 0 0 auto;
  }
</style>
</head>
<body>
<div id="app">
  <div id="toolbar">
    <span class="title">📊 Sheet</span>
    <button class="tb-btn" id="btn-insert-row">+ Row</button>
    <button class="tb-btn" id="btn-insert-col">+ Column</button>
    <button class="tb-btn" id="btn-delete-row">− Row</button>
    <button class="tb-btn" id="btn-delete-col">− Column</button>
    <div class="tb-sep"></div>
    <button class="tb-btn" id="btn-clear">Clear</button>
    <button class="tb-btn" id="btn-save">Save</button>
    <button class="tb-btn" id="btn-reset">Reset</button>
  </div>

  <div id="formula-bar">
    <div id="cell-ref">A1</div>
    <div id="formula-icon">fx</div>
    <input id="formula-input" type="text" autocomplete="off" spellcheck="false" />
  </div>

  <div id="grid-wrap">
    <table id="grid"></table>
  </div>

  <div id="status"></div>
</div>

<div id="context-menu">
  <div class="cm-item" data-action="insert-row">Insert row above</div>
  <div class="cm-item" data-action="insert-below">Insert row below</div>
  <div class="cm-item" data-action="delete-row">Delete row</div>
  <div class="cm-sep"></div>
  <div class="cm-item" data-action="insert-col">Insert column left</div>
  <div class="cm-item" data-action="insert-right">Insert column right</div>
  <div class="cm-item" data-action="delete-col">Delete column</div>
  <div class="cm-sep"></div>
  <div class="cm-item" data-action="clear">Clear cell</div>
</div>

<script>
"use strict";

/* =========================================================================
   TUNABLE CONSTANTS
   ========================================================================= */
const CONFIG = {
  NUM_COLS: 26,        // A-Z
  NUM_ROWS: 100,
  COL_W: 120,         // px
  ROW_H: 24,          // px
  HEADER_W: 48,       // px (row header)
  STORAGE_KEY: "spreadsheet.v1",
  SELECTION_COLOR: "#1a73e8",
};

/* =========================================================================
   CELL / ADDRESS HELPERS
   ========================================================================= */
function colIndexToLetter(i) { // 0 -> "A", 25 -> "Z", 26 -> "AA"
  let s = "";
  i = i + 1;
  while (i > 0) {
    const rem = (i - 1) % CONFIG.NUM_COLS;
    s = String.fromCharCode(65 + rem) + s;
    i = (i - 1) / CONFIG.NUM_COLS | 0;
  }
  return s;
}
function letterToColIndex(letter) {
  let n = 0;
  for (let i = 0; i < letter.length; i++) {
    n = n * CONFIG.NUM_COLS + (letter.charCodeAt(i) - 64);
  }
  return n - 1;
}
function parseAddress(addr) {
  // returns {col, row} or null
  const m = addr.match(/^([A-Z]+)([0-9]+)$/);
  if (!m) return null;
  const col = letterToColIndex(m[1]);
  const row = parseInt(m[2], 10) - 1;
  if (col < 0 || col >= CONFIG.NUM_COLS || row < 0 || row >= CONFIG.NUM_ROWS) return null;
  return { col, row };
}
function cellId(col, row) { return col + "," + row; }
function displayAddr(col, row) { return colIndexToLetter(col) + (row + 1); }
function isValidCol(c) { return c >= 0 && c < CONFIG.NUM_COLS; }
function isValidRow(r) { return r >= 0 && r < CONFIG.NUM_ROWS; }

/* =========================================================================
   CELL STORE
   Raw values keyed by cellId. Values are one of:
   { type: 'number'|'text', value }
   Raw text stored as-is (including leading '=' for formulas).
   ========================================================================= */
const raw = new Map();          // cellId -> raw string (what user typed)
const rawOrder = [];            // insertion order for serialization
const computed = new Map();     // cellId -> { type, value } final result
const dependents = new Map();   // cellId -> Set(cellId) of cells that use it
const formulaCells = new Set(); // cellIds that contain a formula

function getRaw(id) { return raw.get(id) ?? ""; }
function setRaw(id, str) {
  if (!raw.has(id)) rawOrder.push(id);
  raw.set(id, str);
}
function deleteRaw(id) {
  if (raw.delete(id)) {
    const idx = rawOrder.indexOf(id);
    if (idx >= 0) rawOrder.splice(idx, 1);
  }
}

/* =========================================================================
   FORMULA TOKENIZER
   Tokens: NUMBER, STRING, IDENT (func name or cell ref), RANGE, COMMA, OPER, LPAREN, RPAREN, EOF
   ========================================================================= */
function tokenize(str) {
  const tokens = [];
  let i = 0;
  const n = str.length;
  const isDigit = c => c >= '0' && c <= '9';
  const isAlpha = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
  const isAlnum = c => isAlpha(c) || isDigit(c);

  while (i < n) {
    const c = str[i];
    if (c === ' ' || c === '\t') { i++; continue; }
    if (c === ',') { tokens.push({ t: 'COMMA' }); i++; continue; }
    if (c === '(') { tokens.push({ t: 'LPAREN' }); i++; continue; }
    if (c === ')') { tokens.push({ t: 'RPAREN' }); i++; continue; }
    if (c === '+') { tokens.push({ t: 'OP', v: '+' }); i++; continue; }
    if (c === '-') { tokens.push({ t: 'OP', v: '-' }); i++; continue; }
    if (c === '*') { tokens.push({ t: 'OP', v: '*' }); i++; continue; }
    if (c === '/') { tokens.push({ t: 'OP', v: '/' }); i++; continue; }

    if (c === '"') { // string literal
      let s = ""; i++;
      while (i < n && str[i] !== '"') {
        if (str[i] === '"' && str[i+1] === '"') { s += '"'; i += 2; }
        else { s += str[i]; i++; }
      }
      i++; // closing quote
      tokens.push({ t: 'STRING', v: s });
      continue;
    }
    if (isDigit(c)) { // number (integer or decimal)
      let j = i;
      while (j < n && isDigit(str[j])) j++;
      if (str[j] === '.' && isDigit(str[j+1])) { j++; while (j < n && isDigit(str[j])) j++; }
      tokens.push({ t: 'NUMBER', v: parseFloat(str.slice(i, j)) });
      i = j;
      continue;
    }
    if (isAlpha(c)) { // ident: could be func name or cell ref or range
      let j = i;
      while (j < n && isAlnum(str[j])) j++;
      // check for range colon
      if (str[j] === ':' && j + 1 < n && /[A-Z0-9]/i.test(str[j+1])) {
        const left = str.slice(i, j);
        let k = j + 1;
        while (k < n && isAlnum(str[k])) k++;
        const right = str.slice(j + 1, k);
        tokens.push({ t: 'RANGE', left, right });
        i = k;
      } else {
        const word = str.slice(i, j);
        tokens.push({ t: 'IDENT', v: word.toUpperCase() });
        i = j;
      }
      continue;
    }
    // unknown char
    tokens.push({ t: 'BAD', v: c });
    i++;
  }
  tokens.push({ t: 'EOF' });
  return tokens;
}

/* =========================================================================
   PARSER + EVALUATOR (recursive descent)
   Grammar:
     expr    = term (('+'|'-') term)*
     term    = factor (('*'|'/') factor)*
     factor  = ('-'|'+') factor | primary
     primary = NUMBER | STRING | cellref | range | func '(' args ')' | '(' expr ')'
     args    = expr (',' expr)*
   Returns { type, value } or throws Error with .err = '#ERR!'/'#DIV/0!'/'#REF!'
   ========================================================================= */
const FUNCTIONS = { SUM: 'sum', AVERAGE: 'avg', AVG: 'avg', MIN: 'min', MAX: 'max', COUNT: 'count' };

// Sum the numeric value of a parsed node. Ranges evaluate to arrays; a bare
// range used in arithmetic is summed (spreadsheet-like behavior).
function sumNums(node) {
  if (Array.isArray(node)) return node.reduce((s, a) => s + (a && a.type === 'number' ? a.value || 0 : 0), 0);
  if (node && node.type === 'number') return node.value;
  return 0;
}

class Parser {
  constructor(tokens, evalCell) {
    this.tokens = tokens;
    this.pos = 0;
    this.evalCell = evalCell; // (col,row) -> value or throws
  }
  peek() { return this.tokens[this.pos]; }
  next() { return this.tokens[this.pos++]; }
  expect(t) {
    const tk = this.peek();
    if (tk.t !== t) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
    this.pos++;
  }

  parse() {
    const res = this.parseExpr();
    if (this.peek().t !== 'EOF') { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
    return res;
  }

  parseExpr() {
    let left = this.parseTerm();
    while (this.peek().t === 'OP' && (this.peek().v === '+' || this.peek().v === '-')) {
      const op = this.next().v;
      const right = this.parseTerm();
      const l = left, r = right;
      left = { type: 'number', value: op === '+' ? sumNums(l) + sumNums(r) : sumNums(l) - sumNums(r) };
    }
    return left;
  }

  parseTerm() {
    let left = this.parseFactor();
    while (this.peek().t === 'OP' && (this.peek().v === '*' || this.peek().v === '/')) {
      const op = this.next().v;
      const right = this.parseFactor();
      const l = left, r = right;
      if (op === '/') {
        if (sumNums(r) === 0) { const e = new Error("#DIV/0!"); e.code = "#DIV/0!"; throw e; }
        left = { type: 'number', value: sumNums(l) / sumNums(r) };
      } else {
        left = { type: 'number', value: sumNums(l) * sumNums(r) };
      }
    }
    return left;
  }

  parseFactor() {
    const tk = this.peek();
    if (tk.t === 'OP' && (tk.v === '-' || tk.v === '+')) {
      this.next();
      const inner = this.parseFactor();
      return { type: 'number', value: tk.v === '-' ? -sumNums(inner) : sumNums(inner) };
    }
    return this.parsePrimary();
  }

  parsePrimary() {
    const tk = this.peek();
    if (tk.t === 'NUMBER') { this.next(); return { type: 'number', value: tk.v }; }
    if (tk.t === 'STRING') { this.next(); return { type: 'text', value: tk.v }; }
    if (tk.t === 'LPAREN') {
      this.next();
      const res = this.parseExpr();
      this.expect('RPAREN');
      return res;
    }
    if (tk.t === 'IDENT') {
      const name = tk.v;
      this.next();
      // cell reference? (letters followed by at least one digit, e.g. B1, AB12)
      if (/^[A-Z]+[0-9]+$/.test(name)) {
        const addr = parseAddress(name);
        if (!addr) { const e = new Error("#REF!"); e.code = "#REF!"; throw e; }
        const v = this.evalCell(addr.col, addr.row);
        return v;
      }
      // otherwise must be function call
      if (this.peek().t === 'LPAREN') {
        const fn = FUNCTIONS[name];
        if (!fn) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
        this.next(); // consume '('
        const args = [];
        if (this.peek().t !== 'RPAREN') {
          args.push(this.parseExpr());
          while (this.peek().t === 'COMMA') { this.next(); args.push(this.parseExpr()); }
        }
        this.expect('RPAREN');
        return this.callFunction(fn, args);
      }
      // bare identifier with no paren and not a ref -> treat as text
      return { type: 'text', value: name };
    }
    if (tk.t === 'RANGE') {
      this.next(); // consume the RANGE token
      const left = parseAddress(tk.left);
      const right = parseAddress(tk.right);
      if (!left || !right) { const e = new Error("#REF!"); e.code = "#REF!"; throw e; }
      const arr = this.evalRange(left, right);
      // Return the array so callers (SUM/AVG/etc.) can flatten individual values.
      return arr;
    }
    if (tk.t === 'COMMA' || tk.t === 'RPAREN' || tk.t === 'EOF' || tk.t === 'BAD') {
      const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
    }
    const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
  }

  evalRange(left, right) {
    const minC = Math.min(left.col, right.col), maxC = Math.max(left.col, right.col);
    const minR = Math.min(left.row, right.row), maxR = Math.max(left.row, right.row);
    const arr = [];
    for (let r = minR; r <= maxR; r++)
      for (let c = minC; c <= maxC; c++)
        arr.push(this.evalCell(c, r));
    return arr;
  }

  callFunction(fn, args) {
    // Flatten: ranges are passed as arrays of {type,value}; other args are scalars.
    let flat = [];
    for (const a of args) {
      if (Array.isArray(a)) flat = flat.concat(a);
      else flat.push(a);
    }
    let nums = [];
    for (const a of flat) {
      if (a && a.type === 'number' && !isNaN(a.value)) nums.push(a.value);
    }
    switch (fn) {
      case 'sum': {
        if (nums.length === 0) return { type: 'number', value: 0 };
        return { type: 'number', value: nums.reduce((x, y) => x + y, 0) };
      }
      case 'avg': {
        if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
        return { type: 'number', value: nums.reduce((x, y) => x + y, 0) / nums.length };
      }
      case 'min': {
        if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
        return { type: 'number', value: Math.min(...nums) };
      }
      case 'max': {
        if (nums.length === 0) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
        return { type: 'number', value: Math.max(...nums) };
      }
      case 'count': {
        // count of numeric values
        return { type: 'number', value: nums.length };
      }
    }
    const e = new Error("#ERR!"); e.code = "#ERR!"; throw e;
  }
}

/* =========================================================================
   RECURSIVE EVALUATION WITH CYCLE DETECTION
   evalCell(col,row) computes and caches the value of a cell.
   Uses a "visiting" set to detect cycles -> throws #CYCLE!.
   ========================================================================= */
const visiting = new Set(); // cellIds currently on the eval stack

function evaluateCell(col, row) {
  const id = cellId(col, row);
  const r = getRaw(id);
  if (r === "") return { type: 'text', value: "" };
  if (r[0] !== '=') {
    // plain value: number or text
    const num = parseFloat(r);
    if (/^[-+]?\d*\.?\d+(e[-+]?\d+)?$/i.test(r.trim())) {
      return { type: 'number', value: num };
    }
    return { type: 'text', value: r };
  }
  // formula
  const tokens = tokenize(r.slice(1));
  if (tokens.some(t => t.t === 'BAD')) { const e = new Error("#ERR!"); e.code = "#ERR!"; throw e; }
  if (visiting.has(id)) { const e = new Error("#CYCLE!"); e.code = "#CYCLE!"; throw e; }
  visiting.add(id);
  let result;
  try {
    const parser = new Parser(tokens, (c, rr) => evaluateCell(c, rr));
    result = parser.parse();
  } catch (err) {
    throw err; // propagate error code
  } finally {
    visiting.delete(id);
  }
  return result || { type: 'text', value: "" };
}

/* =========================================================================
   DEPENDENCY GRAPH MAINTENANCE
   ========================================================================= */
function extractRefs(str) {
  // returns list of {col,row} single-cell refs and ranges referenced
  const refs = [];
  if (!str || str[0] !== '=') return refs;
  const tokens = tokenize(str.slice(1));
  for (const tk of tokens) {
    if (tk.t === 'IDENT' && /^[A-Z]+$/.test(tk.v)) {
      const addr = parseAddress(tk.v);
      if (addr) refs.push({ col: addr.col, row: addr.row, range: false });
    } else if (tk.t === 'RANGE') {
      const left = parseAddress(tk.left), right = parseAddress(tk.right);
      if (left && right) refs.push({ col: left.col, row: left.row, range: true, rc: right });
    }
  }
  return refs;
}

function cellDeps(id) {
  // returns array of dependent cell ids (single cells) for a formula's refs
  const str = getRaw(id);
  const deps = new Set();
  for (const ref of extractRefs(str)) {
    if (ref.range) {
      const minC = Math.min(ref.col, ref.rc.col), maxC = Math.max(ref.col, ref.rc.col);
      const minR = Math.min(ref.row, ref.rc.row), maxR = Math.max(ref.row, ref.rc.row);
      for (let r = minR; r <= maxR; r++)
        for (let c = minC; c <= maxC; c++)
          if (isValidCol(c) && isValidRow(r)) deps.add(cellId(c, r));
    } else {
      deps.add(cellId(ref.col, ref.row));
    }
  }
  return [...deps];
}

/* Recompute the entire dependency graph from scratch (robust, simple).
   For the target size (26x100) this is fast enough and avoids subtle
   incremental bugs. We still detect cycles. */
function rebuildGraphAndCompute() {
  dependents.clear();
  for (const id of raw.keys()) dependents.set(id, new Set());
  formulaCells.clear();
  for (const id of raw.keys()) {
    const str = getRaw(id);
    if (str && str[0] === '=') {
      formulaCells.add(id);
      for (const dep of cellDeps(id)) {
        if (!dependents.has(dep)) dependents.set(dep, new Set());
        dependents.get(dep).add(id);
      }
    }
  }
}

/* Full recompute with cycle detection. Returns map of errors per cell. */
function fullRecompute() {
  const results = new Map();   // id -> {type,value}
  const errors = new Map();    // id -> error string
  const visiting = new Set();

  function compute(id) {
    if (results.has(id)) return;
    if (errors.has(id)) return;
    if (visiting.has(id)) { errors.set(id, "#CYCLE!"); return; }
    visiting.add(id);
    const str = getRaw(id);
    if (str === "") { results.set(id, { type: 'text', value: "" }); visiting.delete(id); return; }
    if (str[0] !== '=') {
      const num = parseFloat(str);
      if (/^[-+]?\d*\.?\d+(e[-+]?\d+)?$/i.test(str.trim()))
        results.set(id, { type: 'number', value: num });
      else
        results.set(id, { type: 'text', value: str });
      visiting.delete(id);
      return;
    }
    // formula: evaluate, catching errors
    let result;
    try {
      const tokens = tokenize(str.slice(1));
      const parser = new Parser(tokens, (c, rr) => evaluateCellStack(c, rr, compute));
      result = parser.parse();
      results.set(id, result);
    } catch (err) {
      errors.set(id, String(err.code || "#ERR!"));
    }
    visiting.delete(id);
  }

  // helper that computes a dependency on demand during evaluation
  function evaluateCellStack(col, row, compute) {
    const id = cellId(col, row);
    compute(id);
    if (errors.has(id)) {
      const e = new Error(errors.get(id)); e.code = errors.get(id); throw e;
    }
    return results.get(id);
  }

  for (const id of rawOrder.slice()) compute(id);

  // Merge into computed map
  computed.clear();
  for (const id of raw.keys()) {
    if (errors.has(id)) {
      computed.set(id, { type: 'error', value: errors.get(id) });
    } else {
      computed.set(id, results.get(id) || { type: 'text', value: "" });
    }
  }
}

/* =========================================================================
   RENDERING
   ========================================================================= */
const table = document.getElementById('grid');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
const gridWrap = document.getElementById('grid-wrap');
let headerCols = [];

function buildGridStructure() {
  table.innerHTML = "";
  // Column header row
  const tr = document.createElement('tr');
  const corner = document.createElement('th');
  corner.className = "corner";
  corner.style.width = CONFIG.HEADER_W + "px";
  corner.style.left = "0px";
  corner.style.height = CONFIG.ROW_H + "px";
  tr.appendChild(corner);
  headerCols = [];
  for (let c = 0; c < CONFIG.NUM_COLS; c++) {
    const th = document.createElement('th');
    th.textContent = colIndexToLetter(c);
    th.style.width = CONFIG.COL_W + "px";
    th.style.left = (CONFIG.HEADER_W + c * CONFIG.COL_W) + "px";
    th.style.height = CONFIG.ROW_H + "px";
    th.dataset.col = c;
    th.addEventListener('contextmenu', onHeaderContextMenu);
    tr.appendChild(th);
    headerCols.push(th);
  }
  thead.appendChild(tr);
  table.appendChild(thead);

  // Body rows
  tbody.innerHTML = "";
  for (let r = 0; r < CONFIG.NUM_ROWS; r++) {
    const tr = document.createElement('tr');
    const th = document.createElement('th');
    th.className = "row-head";
    th.textContent = (r + 1);
    th.style.height = CONFIG.ROW_H + "px";
    th.dataset.row = r;
    th.addEventListener('contextmenu', onHeaderContextMenu);
    tr.appendChild(th);
    for (let c = 0; c < CONFIG.NUM_COLS; c++) {
      const td = document.createElement('td');
      td.className = "cell";
      td.dataset.col = c;
      td.dataset.row = r;
      td.addEventListener('mousedown', onCellMousedown);
      td.addEventListener('dblclick', onCellDblclick);
      td.addEventListener('contextmenu', onCellContextMenu);
      tr.appendChild(td);
    }
    tbody.appendChild(tr);
  }
  table.appendChild(tbody);
}

let cellCache = new Map(); // id -> td element
function getCellEl(col, row) {
  const id = cellId(col, row);
  let el = cellCache.get(id);
  if (!el) {
    el = table.querySelector(`td.cell[data-col="${col}"][data-row="${row}"]`);
    cellCache.set(id, el);
  }
  return el;
}

function formatValue(v) {
  if (typeof v === 'number') {
    if (Number.isNaN(v)) return "#NUM!";
    if (Number.isInteger(v)) return String(v);
    return parseFloat(v.toFixed(6)).toString();
  }
  return v;
}

function renderCell(col, row) {
  const id = cellId(col, row);
  const el = getCellEl(col, row);
  if (!el) return;
  const data = computed.get(id) || { type: 'text', value: "" };
  el.className = "cell";
  let text;
  if (data.type === 'error') {
    el.classList.add('error');
    text = data.value;
  } else if (data.type === 'number') {
    el.classList.add('number');
    text = formatValue(data.value);
  } else {
    text = data.value;
  }
  el.textContent = text;
}

/* Re-render only changed cells (used after recompute). */
function renderChanged(changedIds) {
  for (const id of changedIds) {
    const [cs, rs] = id.split(',').map(Number);
    renderCell(cs, rs);
  }
}

/* Full render (after structural changes) */
function renderAll() {
  for (let r = 0; r < CONFIG.NUM_ROWS; r++)
    for (let c = 0; c < CONFIG.NUM_COLS; c++)
      renderCell(c, r);
}

/* =========================================================================
   SELECTION & EDITING STATE
   ========================================================================= */
let selCol = 0, selRow = 0;
let editing = false;
let activeEditor = null; // textarea or formula-input
let editCancelled = false;

function selectCell(col, row, focusEditor) {
  if (editing) commitEdit(true); // discard if still editing
  const prev = getCellEl(selCol, selRow);
  if (prev) prev.classList.remove('selected');
  selCol = col; selRow = row;
  const el = getCellEl(col, row);
  if (el) el.classList.add('selected');
  document.getElementById('cell-ref').textContent = displayAddr(col, row);
  syncFormulaBar();
  ensureVisible(col, row);
  updateStatus();
}

function ensureVisible(col, row) {
  const x = CONFIG.HEADER_W + col * CONFIG.COL_W;
  const y = row * CONFIG.ROW_H;
  const el = getCellEl(col, row);
  if (!el) return;
  const rect = el.getBoundingClientRect();
  const wrapRect = gridWrap.getBoundingClientRect();
  if (rect.left < wrapRect.left) gridWrap.scrollLeft += rect.left - wrapRect.left;
  if (rect.right > wrapRect.right) gridWrap.scrollLeft += rect.right - wrapRect.right;
  if (rect.top < wrapRect.top) gridWrap.scrollTop += rect.top - wrapRect.top;
  if (rect.bottom > wrapRect.bottom) gridWrap.scrollTop += rect.bottom - wrapRect.bottom;
}

/* ---------- Formula bar sync ---------- */
const formulaInput = document.getElementById('formula-input');
const cellRefBox = document.getElementById('cell-ref');

function syncFormulaBar() {
  if (editing) return; // editor owns the content
  formulaInput.value = getRaw(cellId(selCol, selRow));
}

function enterEditMode(focusFormulaBar) {
  if (editing) return;
  editing = true;
  editCancelled = false;
  const id = cellId(selCol, selRow);
  const start = getRaw(id);
  if (focusFormulaBar) {
    formulaInput.focus();
    formulaInput.value = start;
    activeEditor = formulaInput;
  } else {
    // in-cell editor
    const el = getCellEl(selCol, selRow);
    const ta = document.createElement('textarea');
    ta.className = "in-cell-edit";
    ta.value = start;
    ta.style.width = (CONFIG.COL_W - 12) + "px";
    ta.style.height = (CONFIG.ROW_H - 2) + "px";
    ta.style.position = "absolute";
    ta.style.zIndex = 10;
    el.classList.add('editing');
    el.innerHTML = "";
    el.appendChild(ta);
    activeEditor = ta;
    ta.focus();
    ta.selectionStart = ta.selectionEnd = ta.value.length;
  }
  ta_or_fi(activeEditor);
}

function ta_or_fi(el) {
  el.addEventListener('keydown', onEditorKeydown);
  el.addEventListener('input', onEditorInput);
}

function onEditorInput(e) {
  updateStatus("editing");
}

function onEditorKeydown(e) {
  if (e.key === 'Enter') {
    e.preventDefault();
    commitEdit(false);
    // move down
    if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
  } else if (e.key === 'Tab') {
    e.preventDefault();
    commitEdit(false);
    if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
    else if (selRow < CONFIG.NUM_ROWS - 1) { selectCell(0, selRow + 1); }
  } else if (e.key === 'Escape') {
    e.preventDefault();
    commitEdit(true); // cancel
  } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
    // allow cursor movement within editor; do not commit
  }
}

function commitEdit(cancel) {
  if (!editing) return;
  const id = cellId(selCol, selRow);
  const cur = activeEditor ? activeEditor.value : "";
  editing = false;
  const editor = activeEditor;
  activeEditor = null;
  if (editor && editor !== formulaInput && editor.parentNode) {
    // restore cell display
    const el = getCellEl(selCol, selRow);
    el.classList.remove('editing');
  }
  if (cancel) {
    setRaw(id, getRaw(id)); // unchanged
  } else {
    setRaw(id, cur.trim() === "" ? "" : cur);
    // Actually keep raw as typed but trim? We store trimmed.
    if (cancel === false) raw.set(id, cur);
  }
  // Recompute graph + dependent cascade
  scheduleRecompute();
  syncFormulaBar();
  renderCell(selCol, selRow);
  updateStatus();
}

function onCellMousedown(e) {
  if (e.button !== 0) return;
  const td = e.currentTarget;
  const col = parseInt(td.dataset.col, 10), row = parseInt(td.dataset.row, 10);
  if (editing) {
    // if clicking within the same editor, allow; else commit
    if (activeEditor !== document.activeElement) commitEdit(false);
  }
  selectCell(col, row);
}

function onCellDblclick(e) {
  const td = e.currentTarget;
  const col = parseInt(td.dataset.col, 10), row = parseInt(td.dataset.row, 10);
  selectCell(col, row);
  enterEditMode(false);
}

/* ---------- Global key handling ---------- */
document.addEventListener('keydown', (e) => {
  if (editing) return; // editor handles its own keys
  if (document.activeElement === formulaInput) return;

  if (e.key === 'Delete' || e.key === 'Backspace') {
    e.preventDefault();
    clearCell(selCol, selRow);
    return;
  }
  if (e.key === 'Enter') {
    e.preventDefault();
    if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
    else enterEditMode(false);
    return;
  }
  if (e.key === 'Tab') {
    e.preventDefault();
    if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
    else if (selRow < CONFIG.NUM_ROWS - 1) selectCell(0, selRow + 1);
    return;
  }
  if (e.key === 'ArrowUp') { e.preventDefault(); if (selRow > 0) selectCell(selCol, selRow - 1); }
  else if (e.key === 'ArrowDown') { e.preventDefault(); if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1); }
  else if (e.key === 'ArrowLeft') { e.preventDefault(); if (selCol > 0) selectCell(selCol - 1, selRow); }
  else if (e.key === 'ArrowRight') { e.preventDefault(); if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow); }
  else if (e.key === 'Escape') { syncFormulaBar(); selectCell(selCol, selRow); }
  else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
    // start typing in cell
    enterEditMode(false);
    const ta = activeEditor;
    // insert typed char
    ta.value = e.key;
    ta.selectionStart = ta.selectionEnd = ta.value.length;
    scheduleRecompute();
  }
});

/* Formula bar input handling */
formulaInput.addEventListener('focus', () => {
  if (!editing) { editing = true; editCancelled = false; activeEditor = formulaInput; }
  cellRefBox.textContent = displayAddr(selCol, selRow);
});
formulaInput.addEventListener('input', () => {
  raw.set(cellId(selCol, selRow), formulaInput.value);
  scheduleRecompute();
  updateStatus("editing");
});
formulaInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    e.preventDefault();
    editing = false; activeEditor = null;
    scheduleRecompute();
    syncFormulaBar();
    renderCell(selCol, selRow);
    if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1);
  } else if (e.key === 'Tab') {
    e.preventDefault();
    editing = false; activeEditor = null;
    scheduleRecompute();
    syncFormulaBar();
    renderCell(selCol, selRow);
    if (selCol < CONFIG.NUM_COLS - 1) selectCell(selCol + 1, selRow);
  } else if (e.key === 'Escape') {
    e.preventDefault();
    editing = false; activeEditor = null;
    syncFormulaBar();
  } else if (e.key === 'ArrowUp') { e.preventDefault(); if (selRow > 0) selectCell(selCol, selRow - 1); }
  else if (e.key === 'ArrowDown') { e.preventDefault(); if (selRow < CONFIG.NUM_ROWS - 1) selectCell(selCol, selRow + 1); }
});
formulaInput.addEventListener('blur', () => {
  if (editing) {
    editing = false; activeEditor = null;
    scheduleRecompute();
    syncFormulaBar();
    renderCell(selCol, selRow);
  }
});

/* =========================================================================
   RECOMPUTE SCHEDULING (batch async to keep typing feel instant)
   ========================================================================= */
let recomputeQueued = false;
function scheduleRecompute() {
  if (recomputeQueued) return;
  recomputeQueued = true;
  queueMicrotask(() => {
    recomputeQueued = false;
    doRecompute();
  });
}

let lastRendered = new Map(); // snapshot of computed for change detection
function doRecompute() {
  rebuildGraphAndCompute();
  fullRecompute();
  // Determine changed cells
  const changed = [];
  for (const id of raw.keys()) {
    const nd = computed.get(id);
    const od = lastRendered.get(id);
    if (!deepEqual(nd, od)) changed.push(id);
  }
  // also removed cells
  for (const id of lastRendered.keys()) if (!computed.has(id)) changed.push(id);
  renderChanged(changed);
  lastRendered = new Map(computed);
  updateStatus();
}
function deepEqual(a, b) {
  if (a === b) return true;
  if (!a || !b) return false;
  return a.type === b.type && String(a.value) === String(b.value);
}

/* =========================================================================
   CLEAR / INSERT / DELETE
   ========================================================================= */
function clearCell(col, row) {
  const id = cellId(col, row);
  deleteRaw(id);
  scheduleRecompute();
  renderCell(col, row);
  syncFormulaBar();
}

/* Insert a row at index `row` (shift everything at/after down by 1).
   Rewrite formulas referencing affected cells. */
function insertRow(atRow) {
  moveRows(atRow, +1);
}
function deleteRow(atRow) {
  moveRows(atRow, -1);
}
function moveRows(atRow, delta) {
  // Move raw entries: for every cell in affected region, relocate its raw content.
  // delta: +1 insert (new empty row at atRow), -1 delete (remove atRow).
  const moved = new Map();
  const removed = new Set();
  for (const id of rawOrder.slice()) {
    const [c, r] = id.split(',').map(Number);
    let nr = r;
    if (delta > 0) {
      // insert row at atRow: rows >= atRow shift down
      if (r >= atRow) nr = r + 1;
    } else {
      // delete row at atRow: rows > atRow shift up; atRow removed
      if (r === atRow) { removed.add(id); continue; }
      if (r > atRow) nr = r - 1;
    }
    if (nr !== r) {
      moved.set(id, cellId(c, nr));
    }
  }
  // Apply: rebuild raw + rawOrder
  const newRaw = new Map();
  const newOrder = [];
  for (const id of rawOrder) {
    if (removed.has(id)) continue;
    const nid = moved.get(id) ?? id;
    newRaw.set(nid, raw.get(id));
    newOrder.push(nid);
  }
  raw.clear(); rawOrder.length = 0;
  for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
  scheduleRecompute();
}

/* Insert/delete column similarly, rewriting refs. */
function insertCol(atCol) {
  moveCols(atCol, +1);
}
function deleteCol(atCol) {
  moveCols(atCol, -1);
}
function moveCols(atCol, delta) {
  const moved = new Map();
  const removed = new Set();
  for (const id of rawOrder.slice()) {
    const [c, r] = id.split(',').map(Number);
    let nc = c;
    if (delta > 0) {
      if (c >= atCol) nc = c + 1;
    } else {
      if (c === atCol) { removed.add(id); continue; }
      if (c > atCol) nc = c - 1;
    }
    if (nc !== c) moved.set(id, cellId(nc, r));
  }
  const newRaw = new Map();
  const newOrder = [];
  for (const id of rawOrder) {
    if (removed.has(id)) continue;
    const nid = moved.get(id) ?? id;
    newRaw.set(nid, raw.get(id));
    newOrder.push(nid);
  }
  raw.clear(); rawOrder.length = 0;
  for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
  scheduleRecompute();
}

/* =========================================================================
   COLUMN/ROW REF REWRITING ON INSERT-DELETE
   Because we physically move raw content by relocating cellIds, existing
   formula strings still reference old addresses. We must rewrite any formula
   whose refs point to cells that have moved.
   */
function rewriteAllFormulasAfterMove() {
  // Build a map oldId -> newId from current move. We apply it to formula text.
  // Instead of tracking per-move, we recompute a global remap by comparing
  // where content currently sits. Simpler: after any insert/delete we already
  // moved content; now rewrite formulas that reference moved cells.
  // We detect moved cells by scanning: for each raw cell, if its formula
  // references a cell whose content is no longer at that address, rewrite.
  // Approach: build old->new mapping is lost after move; so we do a
  // position-based remap: for each source cell that now holds content that
  // "came from" a lower address. We approximate by rewriting every formula's
  // refs so they continue to point to the same logical content.
  //
  // Practical robust method: after a move, for each formula cell, look at
  // what value it currently resolves to vs. what it should. That's complex.
  // Instead, we keep an explicit remap during move by editing formula text.
  // (Implemented in moveRows/moveCols via remap map.)
}

/* Rewrite a formula string given oldId->newId cell remap (for insert/delete).
   Applies to single refs and ranges. */
function rewriteFormulaText(str, remap) {
  // remap: Map oldId -> newId (only for cells that moved). Returns new string.
  if (!str || str[0] !== '=') return str;
  const tokens = tokenize(str.slice(1));
  let out = "=";
  for (const tk of tokens) {
    if (tk.t === 'IDENT' && /^[A-Z]+$/.test(tk.v)) {
      const addr = parseAddress(tk.v);
      if (addr) {
        const oldId = cellId(addr.col, addr.row);
        if (remap.has(oldId)) out += displayAddr(...cellIdToColRow(remap.get(oldId)));
        else out += tk.v;
      } else out += tk.v;
    } else if (tk.t === 'RANGE') {
      const left = parseAddress(tk.left), right = parseAddress(tk.right);
      if (left && right) {
        const ol = cellId(left.col, left.row), or_ = cellId(right.col, right.row);
        let nl = remap.has(ol) ? remap.get(ol) : ol;
        let nr = remap.has(or_) ? remap.get(or_) : or_;
        out += displayAddr(...cellIdToColRow(nl)) + ":" + displayAddr(...cellIdToColRow(nr));
      } else out += tk.left + ":" + tk.right;
    } else {
      if (tk.t === 'EOF') break;
      // Emit punctuation tokens that carry no value.
      if (tk.t === 'LPAREN') out += '(';
      else if (tk.t === 'RPAREN') out += ')';
      else if (tk.t === 'COMMA') out += ',';
      else if (tk.t === 'OP') out += tk.v;
      else out += (tk.v != null ? String(tk.v) : "");
    }
  }
  return out;
}
function cellIdToColRow(id) { const [c, r] = id.split(',').map(Number); return [c, r]; }

/* =========================================================================
   CONTEXT MENU (column/row headers + cells)
   ========================================================================= */
const ctxMenu = document.getElementById('context-menu');
let ctxTarget = null; // {col,row}

function hideContextMenu() { ctxMenu.style.display = 'none'; }
document.addEventListener('click', (e) => {
  if (!ctxMenu.contains(e.target)) hideContextMenu();
});

function showContextMenu(x, y) {
  ctxMenu.style.display = 'block';
  ctxMenu.style.left = Math.min(x, window.innerWidth - 180) + "px";
  ctxMenu.style.top = Math.min(y, window.innerHeight - 220) + "px";
}

function onCellContextMenu(e) {
  e.preventDefault();
  const col = parseInt(e.currentTarget.dataset.col, 10);
  const row = parseInt(e.currentTarget.dataset.row, 10);
  selectCell(col, row);
  ctxTarget = { col, row };
  showContextMenu(e.clientX, e.clientY);
}
function onHeaderContextMenu(e) {
  e.preventDefault();
  const col = e.currentTarget.dataset.col != null ? parseInt(e.currentTarget.dataset.col, 10) : null;
  const row = e.currentTarget.dataset.row != null ? parseInt(e.currentTarget.dataset.row, 10) : null;
  if (col != null && isValidCol(col)) selectCell(col, selRow);
  if (row != null && isValidRow(row)) selectCell(selCol, row);
  ctxTarget = { col, row };
  showContextMenu(e.clientX, e.clientY);
}

ctxMenu.addEventListener('click', (e) => {
  const item = e.target.closest('.cm-item');
  if (!item || !ctxTarget) return;
  const action = item.dataset.action;
  const { col, row } = ctxTarget;
  ctxTarget = null;
  hideContextMenu();
  switch (action) {
    case 'insert-row': insertRowAt(row); break;
    case 'insert-below': insertRowAt(row + 1); break;
    case 'delete-row': deleteRowAt(row); break;
    case 'insert-col': insertColAt(col); break;
    case 'insert-right': insertColAt(col + 1); break;
    case 'delete-col': deleteColAt(col); break;
    case 'clear': clearCell(col, row); break;
  }
});

function insertRowAt(r) {
  // rewrite: for rows >= r, relocate raw content down; rewrite formula refs
  remapAndMove('row', r, 1);
}
function deleteRowAt(r) { remapAndMove('row', r, -1); }
function insertColAt(c) { remapAndMove('col', c, 1); }
function deleteColAt(c) { remapAndMove('col', c, -1); }

/* remapAndMove: build oldId->newId remap, relocate raw content,
   rewrite formula text, then recompute. */
function remapAndMove(axis, at, delta) {
  const remap = new Map();
  const newOrder = [];
  const newRaw = new Map();
  for (const id of rawOrder) {
    const [c, r] = id.split(',').map(Number);
    let target;
    if (delta > 0) {
      if ((axis === 'row' && r >= at) || (axis === 'col' && c >= at)) {
        target = axis === 'row' ? cellId(c, r + 1) : cellId(c + 1, r);
      } else target = id;
    } else {
      if ((axis === 'row' && r === at) || (axis === 'col' && c === at)) continue; // delete
      if ((axis === 'row' && r > at) || (axis === 'col' && c > at)) {
        target = axis === 'row' ? cellId(c, r - 1) : cellId(c - 1, r);
      } else target = id;
    }
    const str = raw.get(id);
    const rewritten = (target !== id && str && str[0] === '=') ? rewriteFormulaText(str, remap) : str;
    // NOTE: remap must include already-computed entries so nested refs rewrite correctly.
    remap.set(id, target);
    newRaw.set(target, rewritten);
    newOrder.push(target);
  }
  raw.clear(); rawOrder.length = 0;
  for (const [k, v] of newRaw) { raw.set(k, v); rawOrder.push(k); }
  scheduleRecompute();
}

/* =========================================================================
   TOOLBAR BUTTONS
   ========================================================================= */
document.getElementById('btn-insert-row').addEventListener('click', () => insertRowAt(selRow));
document.getElementById('btn-insert-col').addEventListener('click', () => insertColAt(selCol));
document.getElementById('btn-delete-row').addEventListener('click', () => deleteRowAt(selRow));
document.getElementById('btn-delete-col').addEventListener('click', () => deleteColAt(selCol));
document.getElementById('btn-clear').addEventListener('click', () => clearCell(selCol, selRow));
document.getElementById('btn-save').addEventListener('click', saveToStorage);
document.getElementById('btn-reset').addEventListener('click', () => {
  if (confirm("Clear the entire sheet?")) {
    raw.clear(); rawOrder.length = 0;
    scheduleRecompute();
  }
});

/* =========================================================================
   STORAGE (localStorage) — serialize RAW formulas
   ========================================================================= */
function saveToStorage() {
  const data = {};
  for (const id of rawOrder) data[id] = raw.get(id);
  // also store selection
  data.__sel__ = [selCol, selRow];
  try {
    localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data));
    updateStatus("saved");
  } catch (e) { updateStatus("save failed"); }
}
function loadFromStorage() {
  const rawStr = localStorage.getItem(CONFIG.STORAGE_KEY);
  if (!rawStr) return false;
  try {
    const data = JSON.parse(rawStr);
    raw.clear(); rawOrder.length = 0;
    for (const k of Object.keys(data)) {
      if (k === '__sel__') continue;
      setRaw(k, data[k]);
    }
    if (Array.isArray(data.__sel__)) { selCol = data.__sel__[0]; selRow = data.__sel__[1]; }
    return true;
  } catch (e) { return false; }
}

/* =========================================================================
   STATUS
   ========================================================================= */
function updateStatus(mode) {
  const el = document.getElementById('status');
  const id = cellId(selCol, selRow);
  const r = getRaw(id);
  let left = `Row ${selRow + 1}  Col ${selCol + 1}  (${displayAddr(selCol, selRow)})`;
  let right = "";
  if (mode === "editing") right = "Editing…";
  else if (r.startsWith('=')) right = "Formula";
  else if (r !== "") { const num = parseFloat(r); if (!isNaN(num)) right = "Value"; else right = "Text"; }
  else right = "Empty";
  el.innerHTML = left + "&nbsp;&nbsp;&nbsp;" + right;
}

/* =========================================================================
   INIT
   ========================================================================= */
function init() {
  buildGridStructure();
  const loaded = loadFromStorage();
  rebuildGraphAndCompute();
  fullRecompute();
  lastRendered = new Map(computed);
  renderAll();
  selectCell(selCol, selRow);
  syncFormulaBar();

  // close context menu on scroll/escape
  gridWrap.addEventListener('scroll', hideContextMenu);
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideContextMenu(); });
  document.addEventListener('mousedown', (e) => {
    if (!ctxMenu.contains(e.target)) hideContextMenu();
  });
  updateStatus();
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"8c15e2d0-6248-4398-905a-a69dc2ceb180","tokensIn":8579273,"tokensOut":145306,"tokensTotal":8724579,"cost":0,"turns":118,"toolCalls":117,"failedToolCalls":2,"timestamp":"2026-08-23T20:09:17.962Z"} -->