← Spreadsheet results

Spreadsheet

ox alpha · full

Initial view of Spreadsheet

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 27,596 bytes · SHA-256 e2780766eb8d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sheet</title>
<style>
/* ============ STYLE ============ */
:root{
  --cell-w: 90px;
  --cell-h: 24px;
  --header-bg:#f8f9fa;
  --header-fg:#5f6368;
  --grid-line:#e1e3e5;
  --sel-color:#1a73e8;
  --err-color:#d93025;
  --bg:#ffffff;
}
*{box-sizing:border-box;}
body{
  margin:0; font-family:'Segoe UI',Arial,sans-serif; font-size:13px;
  background:var(--bg); color:#202124; overflow:hidden;
  display:flex; flex-direction:column; height:100vh;
}
/* Toolbar */
#toolbar{
  display:flex; gap:6px; padding:6px 10px; border-bottom:1px solid var(--grid-line);
  background:var(--header-bg); align-items:center;
}
#toolbar button{
  font-size:12px; padding:4px 10px; border:1px solid #dadce0; border-radius:4px;
  background:#fff; cursor:pointer; color:#202124;
}
#toolbar button:hover{background:#f1f3f4;}
#toolbar .title{font-weight:600; margin-right:12px; color:#5f6368;}
/* Formula bar */
#fbar-row{
  display:flex; align-items:center; border-bottom:1px solid var(--grid-line);
  background:#fff; height:30px; flex:0 0 auto;
}
#cell-ref{
  width:70px; text-align:center; font-weight:600; color:#5f6368;
  border-right:1px solid var(--grid-line); height:100%; line-height:30px;
  background:var(--header-bg); flex:0 0 auto; user-select:none;
}
#fx{padding:0 8px; color:#5f6368; font-style:italic; user-select:none;}
#fbar{
  flex:1; border:none; outline:none; font-family:Consolas,Menlo,monospace;
  font-size:13px; color:#202124; height:100%; padding:0 8px;
  border-left:1px solid var(--grid-line);
}
/* Grid */
#grid-wrap{flex:1; overflow:auto; position:relative;}
table{border-collapse:collapse; table-layout:fixed;}
th,td{
  width:var(--cell-w); min-width:var(--cell-w); max-width:var(--cell-w);
  height:var(--cell-h); padding:0 5px; border-right:1px solid var(--grid-line);
  border-bottom:1px solid var(--grid-line); white-space:nowrap; overflow:hidden;
  text-overflow:ellipsis; background:#fff;
}
th{
  background:var(--header-bg); color:var(--header-fg); font-weight:500;
  text-align:center; font-size:11px; user-select:none; cursor:pointer;
}
thead th{position:sticky; top:0; z-index:3;}
tbody th{position:sticky; left:0; z-index:2;}
thead th:first-child{left:0; z-index:5;}
td{cursor:cell; font-size:13px;}
td.num{text-align:right;}
td.err{color:var(--err-color); font-weight:600;}
td.sel{
  outline:2px solid var(--sel-color); outline-offset:-2px;
}
/* In-cell editor */
#cell-input{
  position:absolute; display:none; border:2px solid var(--sel-color);
  outline:none; font-family:Consolas,Menlo,monospace; font-size:13px;
  padding:0 3px; z-index:10; background:#fff;
}
/* Context menu */
#ctxmenu{
  display:none; position:absolute; z-index:100; background:#fff;
  border:1px solid #dadce0; border-radius:4px; box-shadow:0 2px 8px rgba(0,0,0,.2);
  min-width:160px; padding:4px 0;
}
#ctxmenu div{
  padding:6px 16px; cursor:pointer; font-size:13px;
}
#ctxmenu div:hover{background:#f1f3f4;}
#ctxmenu div.danger{color:var(--err-color);}
</style>
</head>
<body>

<div id="toolbar">
  <span class="title">Sheet</span>
  <button id="btn-ir">+ Row</button>
  <button id="btn-dr">− Row</button>
  <button id="btn-ic">+ Col</button>
  <button id="btn-dc">− Col</button>
</div>

<div id="fbar-row">
  <div id="cell-ref">A1</div>
  <div id="fx">fx</div>
  <input id="fbar" autocomplete="off" spellcheck="false">
</div>

<div id="grid-wrap">
  <table id="grid">
    <thead id="thead"></thead>
    <tbody id="tbody"></tbody>
  </table>
  <input id="cell-input" autocomplete="off" spellcheck="false">
</div>

<div id="ctxmenu"></div>

<script>
"use strict";
/* ============================================================
 * TUNABLE CONSTANTS
 * ============================================================ */
const CFG = {
  ROWS: 100,
  COLS: 26,
  CELL_W: 90,          // px (keep in sync with --cell-w)
  CELL_H: 24,
  STORAGE_KEY: 'sheet-app-v1',
  ERR_REF:   '#REF!',
  ERR_DIV:   '#DIV/0!',
  ERR_PARSE: '#ERR!',
  ERR_CYCLE: '#CYCLE!',
};

/* ============================================================
 * COLUMN NAME UTILITIES
 * ============================================================ */
function colName(c){ // 0-based -> letters
  let s='';
  c=c+1;
  while(c>0){ c--; s=String.fromCharCode(65+(c%26))+s; c=Math.floor(c/26); }
  return s;
}
function colIndex(name){ // letters -> 0-based
  let n=0;
  for(const ch of name.toUpperCase()) n=n*26+(ch.charCodeAt(0)-64);
  return n-1;
}
const key=(r,c)=>r+','+c;
const inGrid=(r,c)=>r>=0&&r<CFG.ROWS&&c>=0&&c<CFG.COLS;

/* ============================================================
 * TOKENIZER
 * ============================================================ */
// token: {type:'num'|'ref'|'ident'|'op'|'err', v, r?, c?}
function tokenize(src){
  const toks=[]; let i=0;
  const isDigit=ch=>ch>='0'&&ch<='9';
  const isLetter=ch=>/[A-Za-z]/.test(ch);
  while(i<src.length){
    const ch=src[i];
    if(ch===' '||ch==='\t'){ i++; continue; }
    if(isDigit(ch)||(ch==='.'&&isDigit(src[i+1]))){
      let j=i; while(j<src.length&&/[0-9.]/.test(src[j]))j++;
      const v=parseFloat(src.slice(i,j));
      if(isNaN(v)) throw new Error('bad number');
      toks.push({type:'num',v}); i=j; continue;
    }
    if(isLetter(ch)){
      let j=i; while(j<src.length&&/[A-Za-z0-9_]/.test(src[j]))j++;
      const word=src.slice(i,j);
      const m=word.match(/^([A-Za-z]{1,3})([0-9]+)$/);
      if(m){ toks.push({type:'ref',r:parseInt(m[2],10)-1,c:colIndex(m[1])}); }
      else toks.push({type:'ident',v:word.toUpperCase()});
      i=j; continue;
    }
    if(ch==='#'){
      let j=i; while(j<src.length&&!/\s/.test(src[j]))j++;
      toks.push({type:'err',v:src.slice(i,j).toUpperCase()}); i=j; continue;
    }
    if('+-*/(),:'.includes(ch)){ toks.push({type:'op',v:ch}); i++; continue; }
    throw new Error('unexpected character '+ch);
  }
  return toks;
}

/* ============================================================
 * PARSER  (recursive descent)
 *
 * expr   := term (('+'|'-') term)*
 * term   := unary (('*'|'/') unary)*
 * unary  := '-' unary | primary
 * primary:= NUM | REF (':' REF)? | IDENT '(' args ')' | '(' expr ')'
 * ============================================================ */
function parseFormula(src){
  const toks=tokenize(src);
  let p=0;
  const peek=()=>toks[p];
  const next=()=>toks[p++];
  const expectOp=v=>{
    const t=next();
    if(!t||t.type!=='op'||t.v!==v) throw new Error('expected '+v);
  };
  function expr(){
    let l=term();
    while(peek()&&peek().type==='op'&&(peek().v==='+'||peek().v==='-')){
      const op=next().v; l={t:'bin',op,l,r:term()};
    }
    return l;
  }
  function term(){
    let l=unary();
    while(peek()&&peek().type==='op'&&(peek().v==='*'||peek().v==='/')){
      const op=next().v; l={t:'bin',op,l,r:unary()};
    }
    return l;
  }
  function unary(){
    if(peek()&&peek().type==='op'&&peek().v==='-'){
      next(); return {t:'neg',e:unary()};
    }
    return primary();
  }
  function primary(){
    const t=next();
    if(!t) throw new Error('unexpected end');
    if(t.type==='num') return {t:'num',v:t.v};
    if(t.type==='err') return {t:'referr'};
    if(t.type==='ref'){
      if(peek()&&peek().type==='op'&&peek().v===':'){
        next();
        const b=next();
        if(!b||b.type!=='ref') throw new Error('bad range');
        return {t:'rng',
          a:{r:Math.min(t.r,b.r),c:Math.min(t.c,b.c)},
          b:{r:Math.max(t.r,b.r),c:Math.max(t.c,b.c)}};
      }
      return {t:'ref',r:t.r,c:t.c};
    }
    if(t.type==='ident'){
      expectOp('(');
      const args=[];
      if(!(peek()&&peek().type==='op'&&peek().v===')')){
        args.push(expr());
        while(peek()&&peek().type==='op'&&peek().v===','){ next(); args.push(expr()); }
      }
      expectOp(')');
      return {t:'fn',name:t.v,args};
    }
    if(t.type==='op'&&t.v==='('){
      const e=expr(); expectOp(')'); return e;
    }
    throw new Error('unexpected token');
  }
  const ast=expr();
  if(p<toks.length) throw new Error('trailing tokens');
  return ast;
}

/* ---- serialize AST back to formula text ---- */
const BIN_PREC={'+' :1,'-':1,'*':2,'/':2};
function ser(n){
  switch(n.t){
    case 'num': return String(n.v);
    case 'ref': return colName(n.c)+(n.r+1);
    case 'referr': return CFG.ERR_REF;
    case 'rng': return colName(n.a.c)+(n.a.r+1)+':'+colName(n.b.c)+(n.b.r+1);
    case 'neg':
      return '-'+(n.e.t==='num'||n.e.t==='ref'?ser(n.e):'('+ser(n.e)+')');
    case 'bin': {
      const my=BIN_PREC[n.op];
      const wrap=child=>(child.t==='bin'&&BIN_PREC[child.op]<my)?'('+ser(child)+')':ser(child);
      let l=wrap(n.l);
      let r=(n.r.t==='bin'&&BIN_PREC[n.r.op]<=my)?'('+ser(n.r)+')':ser(n.r);
      return l+n.op+r;
    }
    case 'fn': return n.name+'('+n.args.map(ser).join(',')+')';
  }
}

/* ============================================================
 * SHEET MODEL  (cells, dependency graph, evaluation)
 * ============================================================ */
let cells=new Map();   // key -> {raw, ast|null, value}
let prec=new Map();      // key -> Set(keys this cell references)
let dep=new Map();       // key -> Set(keys that reference this cell)

function isErr(v){ return typeof v==='string'&&v.startsWith('#'); }

function getCell(k){ return cells.get(k); }

function addEdge(from,to){
  if(!prec.has(from)) prec.set(from,new Set());
  prec.get(from).add(to);
  if(!dep.has(to)) dep.set(to,new Set());
  dep.get(to).add(from);
}
function removeEdges(k){
  const ps=prec.get(k);
  if(ps) for(const p of ps){ const d=dep.get(p); if(d){ d.delete(k); if(!d.size)dep.delete(p);} }
  prec.delete(k);
}

/* extract all referenced keys from an AST (ranges expand fully) */
function astRefs(ast,out){
  if(!ast)return;
  switch(ast.t){
    case 'ref': out.add(key(ast.r,ast.c)); break;
    case 'rng':
      for(let r=ast.a.r;r<=ast.b.r;r++)
        for(let c=ast.a.c;c<=ast.b.c;c++) out.add(key(r,c));
      break;
    case 'neg': astRefs(ast.e,out); break;
    case 'bin': astRefs(ast.l,out); astRefs(ast.r,out); break;
    case 'fn': ast.args.forEach(a=>astRefs(a,out)); break;
  }
}

/* evaluate an AST against current cached values */
function evalNode(n){
  switch(n.t){
    case 'num': return n.v;
    case 'referr': return CFG.ERR_REF;
    case 'ref': {
      if(!inGrid(n.r,n.c)) return CFG.ERR_REF;
      const cell=cells.get(key(n.r,n.c));
      if(!cell||cell.raw==='') return 0;
      return cell.value;
    }
    case 'neg': {
      const v=evalNode(n.e);
      return typeof v==='number'?-v:v; // errors pass through
    }
    case 'bin': {
      const l=evalNode(n.l), r=evalNode(n.r);
      if(isErr(l))return l; if(isErr(r))return r;
      if(typeof l!=='number'||typeof r!=='number') return CFG.ERR_PARSE;
      switch(n.op){
        case '+': return l+r;
        case '-': return l-r;
        case '*': return l*r;
        case '/': return r===0?CFG.ERR_DIV:l/r;
      }
    }
    case 'fn': return evalFn(n);
  }
  return CFG.ERR_PARSE;
}

function collectArgs(n){
  const vals=[];
  for(const a of n.args){
    if(a.t==='rng'){
      for(let r=a.a.r;r<=a.b.r;r++)
        for(let c=a.a.c;c<=a.b.c;c++){
          const cell=cells.get(key(r,c));
          if(cell&&cell.raw!=='') vals.push(cell.value);
        }
    } else {
      const v=evalNode(a);
      if(!(a.t==='ref'&&!cells.get(key(a.r,a.c)))) vals.push(v);
    }
  }
  return vals;
}
const nums=vs=>vs.filter(v=>typeof v==='number');

function evalFn(n){
  const vs=collectArgs(n);
  switch(n.name){
    case 'COUNT': return nums(vs.filter(v=>!isErr(v))).length;
    case 'SUM': {
      for(const v of vs) if(isErr(v)) return v;
      return nums(vs).reduce((a,b)=>a+b,0);
    }
    case 'AVG': case 'AVERAGE': {
      for(const v of vs) if(isErr(v)) return v;
      const ns=nums(vs);
      if(!ns.length) return CFG.ERR_DIV;
      return ns.reduce((a,b)=>a+b,0)/ns.length;
    }
    case 'MIN': {
      for(const v of vs) if(isErr(v)) return v;
      const ns=nums(vs); return ns.length?Math.min(...ns):0;
    }
    case 'MAX': {
      for(const v of vs) if(isErr(v)) return v;
      const ns=nums(vs); return ns.length?Math.max(...ns):0;
    }
  }
  return CFG.ERR_PARSE;
}

/* compute display value of a cell from its raw content */
function computeValue(cell){
  const raw=cell.raw.trim();
  if(raw==='') { cell.value=''; return; }
  if(raw[0]==='='){
    try{
      cell.ast=parseFormula(raw.slice(1));
      cell.value=evalNode(cell.ast);
    }catch(e){
      cell.ast=null;
      cell.value=CFG.ERR_PARSE;
    }
  } else {
    cell.ast=null;
    const n=Number(raw);
    cell.value=(raw!==''&&!isNaN(n))?n:raw;
  }
}

/* --- recalculation: only dependents of changed key, topo order --- */
function recalcFrom(startKey){
  // gather transitive dependents (including startKey)
  const affected=new Set([startKey]);
  const stack=[startKey];
  while(stack.length){
    const k=stack.pop();
    const ds=dep.get(k);
    if(ds) for(const d of ds) if(!affected.has(d)){ affected.add(d); stack.push(d); }
  }
  // Kahn topological sort restricted to affected subgraph
  const indeg=new Map();
  for(const k of affected){
    let n=0;
    const ps=prec.get(k);
    if(ps) for(const p of ps) if(affected.has(p)) n++;
    indeg.set(k,n);
  }
  const q=[];
  for(const [k,n] of indeg) if(n===0) q.push(k);
  const done=new Set();
  while(q.length){
    const k=q.shift();
    done.add(k);
    const cell=cells.get(k);
    if(cell) computeValue(cell);
    const ds=dep.get(k);
    if(ds) for(const d of ds) if(affected.has(d)){
      indeg.set(d,indeg.get(d)-1);
      if(indeg.get(d)===0) q.push(d);
    }
  }
  // anything unprocessed is part of / downstream of a cycle
  for(const k of affected){
    if(done.has(k))continue;
    const cell=cells.get(k);
    if(cell){ cell.value=isErr(cell.value)&&cell.value===CFG.ERR_CYCLE?cell.value:CFG.ERR_CYCLE; }
    renderCellKey(k);
  }
  for(const k of done) renderCellKey(k);
  scheduleSave();
}

function fullRecalc(){
  // topo over entire sheet
  const indeg=new Map();
  for(const k of cells.keys()){
    let n=0; const ps=prec.get(k);
    if(ps) for(const p of ps) if(cells.has(p)) n++;
    indeg.set(k,n);
  }
  const q=[]; for(const [k,n] of indeg) if(n===0)q.push(k);
  const done=new Set();
  while(q.length){
    const k=q.shift(); done.add(k);
    const cell=cells.get(k); if(cell)computeValue(cell);
    const ds=dep.get(k);
    if(ds)for(const d of ds) if(indeg.has(d)){
      indeg.set(d,indeg.get(d)-1);
      if(indeg.get(d)===0)q.push(d);
    }
  }
  for(const [k] of indeg) if(!done.has(k)){ const c=cells.get(k); if(c)c.value=CFG.ERR_CYCLE; }
  renderAll();
  scheduleSave();
}

/* set raw content of a cell and recalc dependents */
function setCellRaw(r,c,raw){
  const k=key(r,c);
  let cell=cells.get(k);
  if(raw===''||raw==null){
    if(!cell)return;
    removeEdges(k); cells.delete(k);
    recalcFrom(k);
    return;
  }
  if(!cell){ cell={raw:'',ast:null,value:''}; cells.set(k,cell); }
  removeEdges(k);
  cell.raw=raw;
  // parse first so we know refs even if eval fails
  if(raw.trim()[0]==='='){
    try{ cell.ast=parseFormula(raw.trim().slice(1)); }
    catch(e){ cell.ast=null; }
  } else cell.ast=null;
  if(cell.ast){
    const refs=new Set(); astRefs(cell.ast,refs);
    for(const t of refs) addEdge(k,t);
  }
  recalcFrom(k);
}

/* ============================================================
 * STRUCTURE OPS: insert/delete rows & columns with rewriting
 * ============================================================ */
function transformAst(ast,fn){
  // fn(refObj) mutates or marks deleted
  let changed=false;
  function walk(n){
    switch(n.t){
      case 'ref': {
        const res=fn(n.r,n.c);
        if(res===null){ n.t='referr'; delete n.r; delete n.c; changed=true; }
        else { if(res.r!==n.r||res.c!==n.c)changed=true; n.r=res.r; n.c=res.c; }
        break;
      }
      case 'rng': {
        const ra=fn(n.a.r,n.a.c), rb=fn(n.b.r,n.b.c);
        if(ra===null||rb===null){ n.t='referr'; delete n.a; delete n.b; changed=true; break; }
        if(ra.r!==n.a.r||ra.c!==n.a.c||rb.r!==n.b.r||rb.c!==n.b.c)changed=true;
        n.a=ra; n.b=rb;
        if(n.a.r>n.b.r||n.a.c>n.b.c){ n.t='referr'; delete n.a; delete n.b; changed=true; }
        break;
      }
      case 'neg': walk(n.e); break;
      case 'bin': walk(n.l); walk(n.r); break;
      case 'fn': n.args.forEach(walk); break;
    }
  }
  walk(ast);
  return changed;
}

function insertRow(at){
  if(CFG.ROWS>=999) return;
  const moved=new Map();
  for(const [k,cell] of cells){
    const [r,c]=k.split(',').map(Number);
    moved.set(k,{r:r>=at?r+1:r,c,cell});
  }
  applyMove(moved);
  for(const [,rec] of moved){
    const cell=rec.cell;
    if(cell.ast){
      const ch=transformAst(cell.ast,(r,c)=>({r:r>=at?r+1:r,c}));
      if(ch) cell.raw='='+ser(cell.ast);
    }
  }
  rebuildAndRecalc();
}
function deleteRow(at){
  const moved=new Map();
  for(const [k,cell] of cells){
    const [r,c]=k.split(',').map(Number);
    if(r===at) continue;
    moved.set(k,{r:r>at?r-1:r,c,cell});
  }
  applyMove(moved);
  for(const [,rec] of moved){
    const cell=rec.cell;
    if(cell.ast){
      const ch=transformAst(cell.ast,(r,c)=>r===at?null:(r>at?{r:r-1,c}:{r,c}));
      if(ch) cell.raw='='+ser(cell.ast);
    }
  }
  rebuildAndRecalc();
}
function insertCol(at){
  const moved=new Map();
  for(const [k,cell] of cells){
    const [r,c]=k.split(',').map(Number);
    moved.set(k,{r,c:c>=at?c+1:c,cell});
  }
  applyMove(moved);
  for(const [,rec] of moved){
    const cell=rec.cell;
    if(cell.ast){
      const ch=transformAst(cell.ast,(r,c)=>({r,c:c>=at?c+1:c}));
      if(ch) cell.raw='='+ser(cell.ast);
    }
  }
  rebuildAndRecalc();
}
function deleteCol(at){
  const moved=new Map();
  for(const [k,cell] of cells){
    const [r,c]=k.split(',').map(Number);
    if(c===at) continue;
    moved.set(k,{r,c:c>at?c-1:c,cell});
  }
  applyMove(moved);
  for(const [,rec] of moved){
    const cell=rec.cell;
    if(cell.ast){
      const ch=transformAst(cell.ast,(r,c)=>c===at?null:(c>at?{r:r,c:c-1}:{r:r,c:c}));
      if(ch) cell.raw='='+ser(cell.ast);
    }
  }
  rebuildAndRecalc();
}
/* move cell records to new coordinates without recomputing */
function applyMove(moved){
  const oldCells=cells;
  cells=new Map();
  prec=new Map(); dep=new Map();
  for(const [,rec] of moved){
    if(!inGrid(rec.r,rec.c)) continue;
    cells.set(key(rec.r,rec.c),rec.cell);
  }
  // rebuild graph
  for(const [k,cell] of cells){
    if(cell.ast){
      const refs=new Set(); astRefs(cell.ast,refs);
      for(const t of refs) addEdge(k,t);
    }
  }
}

function rebuildAndRecalc(){
  prec=new Map(); dep=new Map();
  for(const [k,cell] of cells){
    if(cell.ast){
      const refs=new Set(); astRefs(cell.ast,refs);
      for(const t of refs) addEdge(k,t);
    }
  }
  buildTable();
  fullRecalc();
}

/* ============================================================
 * PERSISTENCE
 * ============================================================ */
let saveTimer=null;
function scheduleSave(){
  clearTimeout(saveTimer);
  saveTimer=setTimeout(saveSheet,300);
}
function saveSheet(){
  const o={};
  for(const [k,cell] of cells) if(cell.raw!=='') o[k]=cell.raw;
  try{ localStorage.setItem(CFG.STORAGE_KEY,JSON.stringify(o)); }catch(e){}
}
function loadSheet(){
  let o=null;
  try{ o=JSON.parse(localStorage.getItem(CFG.STORAGE_KEY)); }catch(e){}
  if(!o||typeof o!=='object')return false;
  for(const [k,raw] of Object.entries(o)){
    const [r,c]=k.split(',').map(Number);
    if(!inGrid(r,c))continue;
    const cell={raw:String(raw),ast:null,value:''};
    cells.set(k,cell);
  }
  return true;
}

/* ============================================================
 * UI: GRID RENDERING & INTERACTION
 * ============================================================ */
const thead=document.getElementById('thead');
const tbody=document.getElementById('tbody');
const gridWrap=document.getElementById('grid-wrap');
const cellInput=document.getElementById('cell-input');
const fbar=document.getElementById('fbar');
const cellRefBox=document.getElementById('cell-ref');
const ctxMenu=document.getElementById('ctxmenu');

let sel={r:0,c:0};
let editing=false;
let editSource=null; // 'cell' | 'fbar'

function buildTable(){
  let h='<tr><th></th>';
  for(let c=0;c<CFG.COLS;c++) h+=`<th data-c="${c}">${colName(c)}</th>`;
  h+='</tr>';
  thead.innerHTML=h;
  let b='';
  for(let r=0;r<CFG.ROWS;r++){
    b+=`<tr><th data-r="${r}">${r+1}</th>`;
    for(let c=0;c<CFG.COLS;c++) b+=`<td data-r="${r}" data-c="${c}"></td>`;
    b+='</tr>';
  }
  tbody.innerHTML=b;
}

function fmtVal(v){
  if(typeof v==='number'){
    // avoid float noise like 0.30000000000000004
    if(Number.isInteger(v))return String(v);
    return String(parseFloat(v.toPrecision(12)));
  }
  return String(v);
}
function renderCellKey(k){
  const [r,c]=k.split(',').map(Number);
  renderCell(r,c);
}
function renderCell(r,c){
  const td=tdAt(r,c);
  if(!td)return;
  const cell=cells.get(key(r,c));
  const v=cell?cell.value:'';
  td.textContent=fmtVal(v);
  td.classList.toggle('num',typeof v==='number');
  td.classList.toggle('err',isErr(v));
}
function renderAll(){
  for(const [k] of cells) renderCellKey(k);
  // clear stale displays
  tbody.querySelectorAll('td.num,td.err').forEach(td=>{
    const k=key(+td.dataset.r,+td.dataset.c);
    if(!cells.has(k)){ td.classList.remove('num','err'); td.textContent=''; }
  });
  updateSel();
}
const tdAt=(r,c)=>tbody.querySelector(`td[data-r="${r}"][data-c="${c}"]`);

function updateSel(){
  tbody.querySelectorAll('td.sel').forEach(td=>td.classList.remove('sel'));
  const td=tdAt(sel.r,sel.c);
  if(td){
    td.classList.add('sel');
    const rect=td.getBoundingClientRect();
    cellInput.style.left=(rect.left)+'px';
    cellInput.style.top=(rect.top)+'px';
    cellInput.style.width=rect.width+'px';
    cellInput.style.height=rect.height+'px';
  }
  cellRefBox.textContent=colName(sel.c)+(sel.r+1);
  if(!editing){
    const cell=cells.get(key(sel.r,sel.c));
    fbar.value=cell?cell.raw:'';
  }
}
function selectCell(r,c){
  if(editing)commitEdit(null);
  sel={r:Math.max(0,Math.min(CFG.ROWS-1,r)),c:Math.max(0,Math.min(CFG.COLS-1,c))};
  updateSel();
  const td=tdAt(sel.r,sel.c);
  if(td)td.scrollIntoView({block:'nearest',inline:'nearest'});
}

/* --- editing --- */
function startEdit(initial,source){
  editing=true; editSource=source;
  const cell=cells.get(key(sel.r,sel.c));
  const cur=cell?cell.raw:'';
  const val=initial!=null?initial:cur;
  fbar.value=val;
  if(source==='cell'){
    cellInput.style.display='block';
    cellInput.value=val;
    cellInput.focus();
    cellInput.setSelectionRange(val.length,val.length);
  } else {
    fbar.focus();
  }
}
function commitEdit(move){ // move: 'down'|'right'|null
  if(!editing)return;
  editing=false; editSource=null;
  cellInput.style.display='none';
  const raw=fbar.value;
  setCellRaw(sel.r,sel.c,raw);
  fbar.value=raw;
  if(move==='down')selectCell(sel.r+1,sel.c);
  else if(move==='right')selectCell(sel.r,sel.c+1);
  else updateSel();
  gridWrap.focus?.();
}
function cancelEdit(){
  editing=false; editSource=null;
  cellInput.style.display='none';
  updateSel();
}

/* --- events --- */
tbody.addEventListener('mousedown',e=>{
  const td=e.target.closest('td');
  if(!td)return;
  selectCell(+td.dataset.r,+td.dataset.c);
  e.preventDefault();
});
tbody.addEventListener('dblclick',e=>{
  const td=e.target.closest('td');
  if(!td)return;
  selectCell(+td.dataset.r,+td.dataset.c);
  startEdit(null,'cell');
});
thead.addEventListener('click',e=>{
  const th=e.target.closest('th[data-c]');
  if(th)selectCell(0,+th.dataset.c);
});
tbody.addEventListener('click',e=>{
  const th=e.target.closest('th[data-r]');
  if(th)selectCell(+th.dataset.r,0);
});

document.addEventListener('keydown',e=>{
  const inInput=e.target===cellInput||e.target===fbar;
  if(editing){
    if(e.key==='Enter'){ e.preventDefault(); commitEdit('down'); }
    else if(e.key==='Tab'){ e.preventDefault(); commitEdit('right'); }
    else if(e.key==='Escape'){ e.preventDefault(); cancelEdit(); }
    return;
  }
  if(inInput)return;
  switch(e.key){
    case 'ArrowUp': e.preventDefault(); selectCell(sel.r-1,sel.c); break;
    case 'ArrowDown': e.preventDefault(); selectCell(sel.r+1,sel.c); break;
    case 'ArrowLeft': e.preventDefault(); selectCell(sel.r,sel.c-1); break;
    case 'ArrowRight': e.preventDefault(); selectCell(sel.r,sel.c+1); break;
    case 'Enter': e.preventDefault(); startEdit(null,'cell'); break;
    case 'Delete': case 'Backspace':
      e.preventDefault(); setCellRaw(sel.r,sel.c,''); updateSel(); break;
    case 'F2': e.preventDefault(); startEdit(null,'cell'); break;
    default:
      if(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){
        e.preventDefault(); startEdit(e.key,'cell');
      }
  }
});

/* formula bar <-> cell editor sync */
fbar.addEventListener('input',()=>{
  if(editing&&editSource==='cell')cellInput.value=fbar.value;
  else if(!editing){ startEdit(fbar.value,'fbar'); }
});
fbar.addEventListener('keydown',e=>{
  if(e.key==='Enter'){ e.preventDefault(); commitEdit('down'); }
  if(e.key==='Escape'){ e.preventDefault(); cancelEdit(); }
});
cellInput.addEventListener('input',()=>{ fbar.value=cellInput.value; });
cellInput.addEventListener('blur',()=>{ if(editing&&editSource==='cell')commitEdit(null); });
window.addEventListener('resize',updateSel);

/* toolbar */
document.getElementById('btn-ir').onclick=()=>insertRow(sel.r);
document.getElementById('btn-dr').onclick=()=>deleteRow(sel.r);
document.getElementById('btn-ic').onclick=()=>insertCol(sel.c);
document.getElementById('btn-dc').onclick=()=>deleteCol(sel.c);

/* header context menus */
function showMenu(x,y,items){
  ctxMenu.innerHTML='';
  for(const it of items){
    const d=document.createElement('div');
    d.textContent=it.label;
    if(it.danger)d.className='danger';
    d.onclick=()=>{ hideMenu(); it.action(); };
    ctxMenu.appendChild(d);
  }
  ctxMenu.style.display='block';
  ctxMenu.style.left=x+'px';
  ctxMenu.style.top=y+'px';
}
function hideMenu(){ ctxMenu.style.display='none'; }
document.addEventListener('mousedown',e=>{
  if(!ctxMenu.contains(e.target))hideMenu();
});
thead.addEventListener('contextmenu',e=>{
  const th=e.target.closest('th[data-c]');
  if(!th)return;
  e.preventDefault();
  const c=+th.dataset.c;
  selectCell(0,c);
  showMenu(e.clientX,e.clientY,[
    {label:'Insert 1 column left',action:()=>insertCol(c)},
    {label:'Insert 1 column right',action:()=>insertCol(c+1)},
    {label:'Delete column',danger:true,action:()=>deleteCol(c)},
  ]);
});
tbody.addEventListener('contextmenu',e=>{
  const th=e.target.closest('th[data-r]');
  if(!th)return;
  e.preventDefault();
  const r=+th.dataset.r;
  selectCell(r,0);
  showMenu(e.clientX,e.clientY,[
    {label:'Insert 1 row above',action:()=>insertRow(r)},
    {label:'Insert 1 row below',action:()=>insertRow(r+1)},
    {label:'Delete row',danger:true,action:()=>deleteRow(r)},
  ]);
});

/* ============================================================
 * INIT
 * ============================================================ */
buildTable();
loadSheet();
rebuildAndRecalc();
updateSel();
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"b211945f-94a0-441e-9b3e-b127fdce49be","tokensIn":376822,"tokensOut":14100,"tokensTotal":390922,"cost":0,"turns":15,"toolCalls":14,"failedToolCalls":0,"timestamp":"2026-08-21T17:18:43.382Z"} -->