← Spreadsheet results

Spreadsheet

muse spark 1.2 contributor · 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 39,552 bytes · SHA-256 1a777db03567
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sheets — Spreadsheet</title>
<style>
  :root{
    --blue:#1a73e8;
    --blue-light:#e8f0fe;
    --border:#dadce0;
    --border-strong:#c4c7cc;
    --bg:#f8f9fa;
    --bg-header:#f1f3f4;
    --text:#202124;
    --text-light:#5f6368;
    --error:#d93025;
    --cell-w:92px;
    --cell-h:26px;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{font-family:Roboto,Arial,sans-serif;color:var(--text);background:#fff;display:flex;flex-direction:column;height:100vh;overflow:hidden}
  /* Toolbar */
  #toolbar{
    display:flex;align-items:center;gap:6px;padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border);
    flex-wrap:wrap;
  }
  #logo{font-size:18px;font-weight:500;color:var(--text-light);display:flex;align-items:center;gap:8px;margin-right:12px}
  #logo span{width:28px;height:28px;background:#34a853;border-radius:4px;display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px}
  .t-group{display:flex;gap:4px;align-items:center;padding:0 8px;border-right:1px solid var(--border);height:32px}
  .t-group:last-child{border:none}
  .t-btn{
    padding:6px 10px;border:1px solid transparent;border-radius:4px;background:#fff;border-color:var(--border);
    font-size:13px;cursor:pointer;color:var(--text);display:flex;align-items:center;gap:6px;white-space:nowrap;
  }
  .t-btn:hover{background:var(--bg-header);border-color:var(--border-strong)}
  .t-btn:active{background:#e8eaed}
  .t-btn svg{width:14px;height:14px;opacity:.7}
  /* Formula bar */
  #formulaBarWrap{
    display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--border);background:#fff;
  }
  #addrBox{
    min-width:72px;height:28px;border:1px solid var(--border);border-radius:4px;display:flex;align-items:center;justify-content:center;
    font-size:13px;color:var(--text-light);background:var(--bg);font-weight:500;
  }
  #fx{color:var(--text-light);font-style:italic;font-size:14px;padding:0 2px}
  #formulaInput{
    flex:1;height:28px;border:1px solid var(--border);border-radius:4px;padding:0 10px;font-size:13px;outline:none;
  }
  #formulaInput:focus{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue)}
  /* Grid */
  #gridWrap{flex:1;overflow:auto;position:relative;background:#fff}
  table{border-collapse:separate;border-spacing:0;table-layout:fixed}
  th,td{border-right:1px solid var(--border);border-bottom:1px solid var(--border);font-size:13px;white-space:nowrap;overflow:hidden}
  th{background:var(--bg-header);color:var(--text-light);font-weight:500;text-align:center;user-select:none;position:sticky;z-index:2}
  th.colHead{width:var(--cell-w);min-width:var(--cell-w);height:24px;top:0;cursor:pointer}
  th.colHead:hover, th.rowHead:hover{background:#e8eaed}
  th.rowHead{width:46px;min-width:46px;text-align:center;left:0;background:var(--bg-header);cursor:pointer;z-index:3}
  th.corner{width:46px;min-width:46px;left:0;top:0;z-index:4;background:var(--bg-header);border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
  td{
    width:var(--cell-w);min-width:var(--cell-w);height:var(--cell-h);padding:0;position:relative;background:#fff;
    vertical-align:middle;
  }
  td .cellContent{
    width:100%;height:100%;padding:2px 6px;display:flex;align-items:center;overflow:hidden;text-overflow:ellipsis;
  }
  td.numeric .cellContent{justify-content:flex-end}
  td.text .cellContent{justify-content:flex-start}
  td.error .cellContent{color:var(--error);justify-content:flex-start;font-style:italic}
  td.selected{outline:2px solid var(--blue);outline-offset:-1px;z-index:1}
  td.active{outline:2px solid var(--blue);outline-offset:-1px}
  td.rangeSel{background:var(--blue-light)!important}
  /* inline editor */
  #cellEditor{
    position:absolute;top:0;left:0;width:100%;height:100%;border:2px solid var(--blue);padding:2px 6px;font-size:13px;outline:none;
    background:#fff;z-index:10;box-shadow:0 2px 6px rgba(0,0,0,.15);
  }
  /* context menu */
  #ctxMenu{
    position:fixed;background:#fff;border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);
    padding:6px 0;min-width:190px;z-index:100;display:none;
  }
  #ctxMenu button{display:block;width:100%;text-align:left;padding:8px 14px;border:none;background:none;font-size:13px;cursor:pointer}
  #ctxMenu button:hover{background:var(--bg-header)}
  #ctxMenu .sep{height:1px;background:var(--border);margin:4px 0}
  /* status */
  #status{padding:4px 12px;font-size:11px;color:var(--text-light);border-top:1px solid var(--border);background:var(--bg);display:flex;justify-content:space-between}
  /* scrollbar styling */
  #gridWrap::-webkit-scrollbar{width:10px;height:10px}
  #gridWrap::-webkit-scrollbar-thumb{background:#c1c1c1;border-radius:10px}
</style>
</head>
<body>
<div id="toolbar">
  <div id="logo"><span>▦</span> Sheets</div>
  <div class="t-group">
    <button class="t-btn" id="btnInsertRowAbove">+ Row above</button>
    <button class="t-btn" id="btnInsertRowBelow">+ Row below</button>
    <button class="t-btn" id="btnDeleteRow">Delete row</button>
  </div>
  <div class="t-group">
    <button class="t-btn" id="btnInsertColLeft">+ Col left</button>
    <button class="t-btn" id="btnInsertColRight">+ Col right</button>
    <button class="t-btn" id="btnDeleteCol">Delete col</button>
  </div>
  <div class="t-group">
    <button class="t-btn" id="btnClear">Clear cell</button>
    <button class="t-btn" id="btnClearAll" title="Clear entire sheet">Clear sheet</button>
  </div>
  <div class="t-group" style="margin-left:auto;color:var(--text-light);font-size:12px" id="calcInfo">Ready</div>
</div>

<div id="formulaBarWrap">
  <div id="addrBox">A1</div>
  <div id="fx">fx</div>
  <input id="formulaInput" placeholder="Enter value or formula (start with =)" autocomplete="off" spellcheck="false">
</div>

<div id="gridWrap">
  <table id="grid"></table>
</div>

<div id="ctxMenu"></div>
<div id="status"><span>26 × 100 — vanilla JS • no eval()</span><span id="statusRight">Local storage: auto-save</span></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  ROWS: 100,
  COLS: 26,
  CELL_WIDTH: 92,
  CELL_HEIGHT: 26,
  STORAGE_KEY: 'sheets_v2_raw',
  COLORS: { blue:'#1a73e8', error:'#d93025', border:'#dadce0' }
};

/* ============================================================
   STATE
   ============================================================ */
let ROWS = CONFIG.ROWS;
let COLS = CONFIG.COLS;
let data = []; // 2D array [row][col] => {raw:string}
let computed = []; // 2D {display:string, error:string|null, isNumber:boolean}
let selRow = 0, selCol = 0;
let editing = false;
let editorEl = null;
let ctxTarget = null; // {type:'row'|'col', index:number}

/* init data */
function initData(){
  data = Array.from({length: ROWS}, ()=> Array.from({length: COLS}, ()=> ({raw:''})));
  computed = Array.from({length: ROWS}, ()=> Array.from({length: COLS}, ()=> ({display:'', error:null, isNumber:false})));
}
function colToIndex(colStr){ let n=0; for(let i=0;i<colStr.length;i++) n=n*26+(colStr.charCodeAt(i)-64); return n-1; }
function indexToCol(idx){ let s=''; idx++; while(idx>0){ let m=(idx-1)%26; s=String.fromCharCode(65+m)+s; idx=Math.floor((idx-1)/26);} return s; }
function addrToPos(addr){ const m=addr.match(/^([A-Z]+)([0-9]+)$/); if(!m) return null; return {col:colToIndex(m[1]), row:parseInt(m[2],10)-1}; }
function posToAddr(r,c){ return indexToCol(c)+(r+1); }

/* ============================================================
   PERSISTENCE
   ============================================================ */
function save(){
  const raw = data.map(row=> row.map(cell=> cell.raw));
  localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({rows:ROWS, cols:COLS, raw}));
  document.getElementById('statusRight').textContent = 'Saved ' + new Date().toLocaleTimeString();
  setTimeout(()=> document.getElementById('statusRight').textContent='Local storage: auto-save', 1500);
}
function load(){
  try{
    const s = localStorage.getItem(CONFIG.STORAGE_KEY);
    if(!s) return false;
    const obj = JSON.parse(s);
    if(!obj.raw) return false;
    ROWS = obj.rows || CONFIG.ROWS;
    COLS = obj.cols || CONFIG.COLS;
    initData();
    // ensure size matches stored
    for(let r=0;r<Math.min(ROWS,obj.raw.length);r++){
      for(let c=0;c<Math.min(COLS,obj.raw[r].length);c++){
        data[r][c].raw = obj.raw[r][c] || '';
      }
    }
    return true;
  }catch(e){ return false; }
}

/* ============================================================
   TOKENIZER
   ============================================================ */
function tokenize(input){
  const tokens=[];
  let i=0;
  const isSpace = c=> /\s/.test(c);
  while(i<input.length){
    const ch=input[i];
    if(isSpace(ch)){ i++; continue; }
    if(ch==='('){ tokens.push({type:'LPAREN'}); i++; continue; }
    if(ch===')'){ tokens.push({type:'RPAREN'}); i++; continue; }
    if(ch===','){ tokens.push({type:'COMMA'}); i++; continue; }
    if(ch===':'){ tokens.push({type:'COLON'}); i++; continue; }
    if('+-*/'.includes(ch)){ tokens.push({type:'OP', value:ch}); i++; continue; }
    if(/[0-9.]/.test(ch)){
      let j=i, dot=0;
      while(j<input.length && /[0-9.]/.test(input[j])){ if(input[j]==='.') dot++; j++; }
      const numStr=input.slice(i,j);
      if(dot>1 || numStr==='.' || isNaN(parseFloat(numStr))) throw new Error('PARSE');
      tokens.push({type:'NUMBER', value:parseFloat(numStr)});
      i=j; continue;
    }
    if(/[A-Za-z]/.test(ch)){
      let j=i;
      while(j<input.length && /[A-Za-z]/.test(input[j])) j++;
      let letters=input.slice(i,j).toUpperCase();
      // check if followed by digits -> cell ref
      let k=j;
      while(k<input.length && /[0-9]/.test(input[k])) k++;
      if(k>j){
        const digits=input.slice(j,k);
        tokens.push({type:'CELL', value:letters+digits});
        i=k;
      }else{
        tokens.push({type:'IDENT', value:letters});
        i=j;
      }
      continue;
    }
    if(ch==='#'){ // allow #REF! literal already in formula? treat as ident for error propagation
      let j=i;
      while(j<input.length && /[^,\s\)\(]/.test(input[j])) j++;
      tokens.push({type:'IDENT', value:input.slice(i,j).toUpperCase()});
      i=j; continue;
    }
    throw new Error('PARSE');
  }
  tokens.push({type:'EOF'});
  return tokens;
}

/* ============================================================
   PARSER (recursive descent)
   ============================================================ */
class Parser{
  constructor(tokens){ this.t=tokens; this.p=0; }
  peek(){ return this.t[this.p]; }
  consume(type, val){
    const cur=this.peek();
    if(type && cur.type!==type) throw new Error('PARSE');
    if(val!==undefined && cur.value!==val) throw new Error('PARSE');
    this.p++; return cur;
  }
  match(type,val){
    const cur=this.peek();
    if(cur.type===type && (val===undefined || cur.value===val)){ this.p++; return true; }
    return false;
  }
  parseExpression(){
    let node=this.parseTerm();
    while(this.peek().type==='OP' && (this.peek().value==='+' || this.peek().value==='-')){
      const op=this.consume('OP').value;
      const right=this.parseTerm();
      node={type:'binop', op, left:node, right};
    }
    return node;
  }
  parseTerm(){
    let node=this.parseFactor();
    while(this.peek().type==='OP' && (this.peek().value==='*' || this.peek().value==='/')){
      const op=this.consume('OP').value;
      const right=this.parseFactor();
      node={type:'binop', op, left:node, right};
    }
    return node;
  }
  parseFactor(){
    if(this.peek().type==='OP' && this.peek().value==='-'){
      this.consume('OP','-');
      const expr=this.parseFactor();
      return {type:'unary', op:'-', expr};
    }
    if(this.peek().type==='OP' && this.peek().value==='+'){
      this.consume('OP','+');
      return this.parseFactor();
    }
    return this.parsePrimary();
  }
  parsePrimary(){
    const cur=this.peek();
    if(cur.type==='NUMBER'){ this.consume('NUMBER'); return {type:'number', value:cur.value}; }
    if(cur.type==='CELL'){
      this.consume('CELL');
      // check for range
      if(this.peek().type==='COLON'){
        this.consume('COLON');
        const nxt=this.peek();
        if(nxt.type!=='CELL') throw new Error('PARSE');
        this.consume('CELL');
        return {type:'range', start:cur.value, end:nxt.value};
      }
      return {type:'cell', ref:cur.value};
    }
    if(cur.type==='IDENT'){
      const name=cur.value;
      // function call must have '('
      if(this.t[this.p+1] && this.t[this.p+1].type==='LPAREN'){
        this.consume('IDENT');
        this.consume('LPAREN');
        const args=[];
        if(this.peek().type!=='RPAREN'){
          while(true){
            // args can be range or expression; range already handled via CELL + COLON inside parseExpression,
            // but to avoid ambiguity we parse expression
            args.push(this.parseExpression());
            if(this.peek().type==='COMMA'){ this.consume('COMMA'); continue; }
            break;
          }
        }
        this.consume('RPAREN');
        return {type:'call', name, args};
      }
      // standalone ident like #REF! -> treat as error literal
      if(name.startsWith('#')){
        this.consume('IDENT');
        return {type:'errorLit', value:name};
      }
      throw new Error('PARSE');
    }
    if(cur.type==='LPAREN'){
      this.consume('LPAREN');
      const node=this.parseExpression();
      this.consume('RPAREN');
      return node;
    }
    throw new Error('PARSE');
  }
  parse(){
    const node=this.parseExpression();
    if(this.peek().type!=='EOF') throw new Error('PARSE');
    return node;
  }
}
function parseFormula(str){
  // str includes leading =
  const body=str.slice(1);
  if(body.trim()==='') throw new Error('PARSE');
  const tokens=tokenize(body);
  const parser=new Parser(tokens);
  return parser.parse();
}

/* ============================================================
   DEPENDENCY & EVALUATION
   ============================================================ */
function isValidAddr(addr){
  const p=addrToPos(addr);
  if(!p) return false;
  return p.row>=0 && p.row<ROWS && p.col>=0 && p.col<COLS;
}
function collectRefs(node, set){
  if(!node) return;
  if(node.type==='cell') set.add(node.ref);
  else if(node.type==='range'){
    // expand range to individual refs for graph
    const a=addrToPos(node.start), b=addrToPos(node.end);
    if(!a||!b) return;
    const r1=Math.min(a.row,b.row), r2=Math.max(a.row,b.row);
    const c1=Math.min(a.col,b.col), c2=Math.max(a.col,b.col);
    for(let r=r1;r<=r2;r++) for(let c=c1;c<=c2;c++) set.add(posToAddr(r,c));
  }else if(node.type==='binop'){ collectRefs(node.left,set); collectRefs(node.right,set); }
  else if(node.type==='unary'){ collectRefs(node.expr,set); }
  else if(node.type==='call'){ node.args.forEach(a=> collectRefs(a,set)); }
}

class EvalError extends Error{
  constructor(code){ super(code); this.code=code; }
}

function toNumber(v){
  if(typeof v==='number') return v;
  if(v===null||v==='') return 0;
  if(typeof v==='string'){
    const n=parseFloat(v);
    if(isNaN(n)) throw new EvalError('#ERR!');
    return n;
  }
  throw new EvalError('#ERR!');
}

function evalNode(node, visiting, cache){
  switch(node.type){
    case 'number': return node.value;
    case 'errorLit': throw new EvalError(node.value.includes('REF')?'#REF!':node.value);
    case 'cell': {
      const pos=addrToPos(node.ref);
      if(!pos || pos.row<0||pos.row>=ROWS||pos.col<0||pos.col>=COLS) throw new EvalError('#REF!');
      const key=posToAddr(pos.row,pos.col);
      // if that cell has error, propagate
      const comp=computed[pos.row][pos.col];
      // if we are in recalc recursion, comp may not be ready; evaluate it
      if(cache && cache.has(key)){
        const v=cache.get(key);
        if(v && v.error) throw new EvalError(v.error);
        return v ? v.value : 0;
      }
      // fallback to direct computed
      if(comp.error) throw new EvalError(comp.error);
      const rawVal=comp.display;
      // if display is error string? already handled
      // determine numeric value
      if(comp.isNumber) return parseFloat(comp.display);
      if(rawVal===''||rawVal===null) return 0;
      const n=parseFloat(rawVal);
      if(!isNaN(n) && String(n)===String(rawVal).trim()) return n;
      // text in arithmetic -> error per spec
      throw new EvalError('#ERR!');
    }
    case 'range': {
      // ranges should only be evaluated inside functions; if evaluated standalone, return array
      const a=addrToPos(node.start), b=addrToPos(node.end);
      if(!a||!b) throw new EvalError('#REF!');
      const vals=[];
      const r1=Math.min(a.row,b.row), r2=Math.max(a.row,b.row);
      const c1=Math.min(a.col,b.col), c2=Math.max(a.col,b.col);
      for(let r=r1;r<=r2;r++) for(let c=c1;c<=c2;c++){
        if(r<0||r>=ROWS||c<0||c>=COLS) throw new EvalError('#REF!');
        const comp=computed[r][c];
        if(comp.error) throw new EvalError(comp.error);
        // for range expansion, collect raw numeric or 0? For functions we handle separately
        vals.push(comp);
      }
      return vals; // special marker
    }
    case 'unary': {
      const v=evalNode(node.expr, visiting, cache);
      if(Array.isArray(v)) throw new EvalError('#ERR!');
      const n=toNumber(v);
      return -n;
    }
    case 'binop': {
      let l=evalNode(node.left, visiting, cache);
      let r=evalNode(node.right, visiting, cache);
      if(Array.isArray(l)||Array.isArray(r)) throw new EvalError('#ERR!');
      // l/r may be numbers
      const ln=toNumber(l), rn=toNumber(r);
      switch(node.op){
        case '+': return ln+rn;
        case '-': return ln-rn;
        case '*': return ln*rn;
        case '/': if(rn===0) throw new EvalError('#DIV/0!'); return ln/rn;
      }
    }
    case 'call': {
      const name=node.name.toUpperCase();
      // expand args: each arg may be range node or expression that evaluates to range array
      let flat=[];
      for(let arg of node.args){
        if(arg.type==='range'){
          const a=addrToPos(arg.start), b=addrToPos(arg.end);
          if(!a||!b) throw new EvalError('#REF!');
          const r1=Math.min(a.row,b.row), r2=Math.max(a.row,b.row);
          const c1=Math.min(a.col,b.col), c2=Math.max(a.col,b.col);
          for(let r=r1;r<=r2;r++) for(let c=c1;c<=c2;c++){
            if(r<0||r>=ROWS||c<0||c>=COLS) throw new EvalError('#REF!');
            const comp=computed[r][c];
            if(comp.error) throw new EvalError(comp.error);
            flat.push(comp);
          }
        }else{
          const v=evalNode(arg, visiting, cache);
          if(Array.isArray(v)){
            // range evaluated inside expression (should not happen)
            v.forEach(comp=> flat.push(comp));
          }else{
            // single value: wrap as pseudo computed
            flat.push({display:String(v), isNumber: typeof v==='number', error:null, value:v});
          }
        }
      }
      // now compute function
      const nums=[];
      let hasNumber=false;
      flat.forEach(item=>{
        // item is computed-like or value wrapper
        let valStr, isNum;
        if(item && typeof item==='object' && 'display' in item){
          if(item.error) throw new EvalError(item.error);
          valStr=item.display;
          isNum=item.isNumber || (!isNaN(parseFloat(valStr)) && valStr!=='' && isFinite(valStr));
        }else{
          valStr=String(item);
          isNum= typeof item==='number' && isFinite(item);
        }
        if(isNum){
          nums.push(parseFloat(valStr));
          hasNumber=true;
        }else if(typeof item==='number'){
          nums.push(item); hasNumber=true;
        }else{
          // check if string numeric
          const n=parseFloat(valStr);
          if(!isNaN(n) && valStr!=='' && String(n)===String(valStr).trim()){
            nums.push(n); hasNumber=true;
          }else{
            // for SUM/AVG ignore text; for COUNT ignore
          }
        }
      });
      if(name==='SUM'){
        return nums.reduce((a,b)=>a+b,0);
      }else if(name==='AVG' || name==='AVERAGE'){
        if(nums.length===0) throw new EvalError('#DIV/0!');
        return nums.reduce((a,b)=>a+b,0)/nums.length;
      }else if(name==='MIN'){
        if(nums.length===0) return 0;
        return Math.min(...nums);
      }else if(name==='MAX'){
        if(nums.length===0) return 0;
        return Math.max(...nums);
      }else if(name==='COUNT'){
        return nums.length;
      }else{
        throw new EvalError('#ERR!');
      }
    }
  }
  throw new EvalError('#ERR!');
}

/* dependency graph + topological recalc */
function recalc(){
  // parse all formulas, build deps
  const astMap = Array.from({length:ROWS}, ()=> Array(COLS).fill(null));
  const parseError = Array.from({length:ROWS}, ()=> Array(COLS).fill(null));
  const depsMap = new Map(); // addr -> Set of addrs it depends on
  const revMap = new Map(); // addr -> Set of dependents

  for(let r=0;r<ROWS;r++){
    for(let c=0;c<COLS;c++){
      const raw=data[r][c].raw;
      const addr=posToAddr(r,c);
      if(raw.startsWith('=')){
        try{
          const ast=parseFormula(raw);
          astMap[r][c]=ast;
          const s=new Set();
          collectRefs(ast,s);
          depsMap.set(addr,s);
          // build reverse
          for(let dep of s){
            if(!revMap.has(dep)) revMap.set(dep,new Set());
            revMap.get(dep).add(addr);
          }
        }catch(e){
          parseError[r][c]='#ERR!';
          depsMap.set(addr,new Set());
        }
      }else{
        depsMap.set(addr,new Set());
      }
    }
  }

  // reset computed
  for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++) computed[r][c]={display:'', error:null, isNumber:false};

  // cycle detection via DFS
  const state=new Map(); // addr -> 0 unvisited,1 visiting,2 done
  const cycleSet=new Set();
  function dfs(addr, stack){
    const st=state.get(addr)||0;
    if(st===1){
      // found cycle: mark all in stack from addr onwards
      const idx=stack.indexOf(addr);
      for(let i=idx;i<stack.length;i++) cycleSet.add(stack[i]);
      cycleSet.add(addr);
      return;
    }
    if(st===2) return;
    state.set(addr,1);
    stack.push(addr);
    const deps=depsMap.get(addr)||new Set();
    for(let dep of deps){
      // only consider deps that are inside sheet; out-of-bounds will be #REF! later, not cycle
      if(!isValidAddr(dep)) continue;
      dfs(dep, stack);
      if(cycleSet.has(dep)) cycleSet.add(addr);
    }
    stack.pop();
    state.set(addr,2);
  }
  for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++) dfs(posToAddr(r,c), []);

  // mark cycles
  for(let addr of cycleSet){
    const p=addrToPos(addr);
    if(p) computed[p.row][p.col]={display:'#CYCLE!', error:'#CYCLE!', isNumber:false};
  }

  // topological order for non-cycle cells: Kahn or DFS postorder
  // Build order via DFS postorder excluding cycles
  const order=[];
  const visited2=new Set();
  function topo(addr){
    if(visited2.has(addr) || cycleSet.has(addr)) return;
    visited2.add(addr);
    const deps=depsMap.get(addr)||new Set();
    for(let dep of deps){
      if(!isValidAddr(dep)) continue;
      if(cycleSet.has(dep)) continue;
      topo(dep);
    }
    order.push(addr);
  }
  for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++) topo(posToAddr(r,c));

  // evaluate in order
  for(let addr of order){
    const p=addrToPos(addr);
    const r=p.row,c=p.col;
    if(computed[r][c].error) continue; // cycle already
    const raw=data[r][c].raw;
    if(parseError[r][c]){
      computed[r][c]={display:parseError[r][c], error:parseError[r][c], isNumber:false};
      continue;
    }
    if(!raw.startsWith('=')){
      // plain value
      if(raw==='' ){
        computed[r][c]={display:'', error:null, isNumber:false};
      }else if(!isNaN(raw) && raw.trim()!=='' && isFinite(raw)){
        // numeric
        computed[r][c]={display:String(parseFloat(raw)), error:null, isNumber:true};
      }else{
        computed[r][c]={display:raw, error:null, isNumber:false};
      }
      continue;
    }
    // formula
    const ast=astMap[r][c];
    // check for #REF! deps that are out of bounds or contain #REF! literal
    let hasRef=false;
    const deps=depsMap.get(addr);
    for(let dep of deps){
      if(dep.includes('#REF') ) { hasRef=true; break; }
      if(!isValidAddr(dep)){ hasRef=true; break; }
    }
    if(hasRef){
      computed[r][c]={display:'#REF!', error:'#REF!', isNumber:false};
      continue;
    }
    // check if any dep is cycle -> propagate cycle
    let depCycle=false;
    for(let dep of deps){
      const dp=addrToPos(dep);
      if(dp && computed[dp.row][dp.col].error==='#CYCLE!'){ depCycle=true; break; }
    }
    if(depCycle){
      computed[r][c]={display:'#CYCLE!', error:'#CYCLE!', isNumber:false};
      continue;
    }
    // check if any dep has error -> propagate that error (first)
    let depErr=null;
    for(let dep of deps){
      const dp=addrToPos(dep);
      if(dp && computed[dp.row][dp.col].error){ depErr=computed[dp.row][dp.col].error; break; }
    }
    if(depErr){
      computed[r][c]={display:depErr, error:depErr, isNumber:false};
      continue;
    }
    try{
      const val=evalNode(ast, null, null);
      if(Array.isArray(val)) throw new EvalError('#ERR!');
      // val is number
      if(typeof val==='number'){
        if(!isFinite(val)) throw new EvalError('#DIV/0!');
        computed[r][c]={display:String(val), error:null, isNumber:true};
      }else{
        computed[r][c]={display:String(val), error:null, isNumber:false};
      }
    }catch(e){
      const code = e instanceof EvalError ? e.code : '#ERR!';
      computed[r][c]={display:code, error:code, isNumber:false};
    }
  }

  // also propagate errors to dependents of cycles that were not in order? Already handled via dep check
  // For cells that depend on error cells but were evaluated earlier (order ensures deps first, so ok)

  document.getElementById('calcInfo').textContent = cycleSet.size? `Cycle: ${cycleSet.size} cell(s)` : 'Ready';
}

/* ============================================================
   FORMULA REWRITING for insert/delete
   ============================================================ */
function rewriteFormula(raw, type, idx, delta){
  // type: 'row'|'col', idx 0-based, delta +1 insert, -1 delete
  if(!raw.startsWith('=')) return raw;
  // if contains #REF! keep as is
  return raw.replace(/([A-Z]+)([0-9]+)/g, (match, colStr, rowStr)=>{
    const c = colToIndex(colStr);
    const r = parseInt(rowStr,10)-1;
    if(type==='row'){
      if(delta===1){
        if(r>=idx) return colStr + (r+1+delta);
      }else{
        if(r===idx) return '#REF!';
        if(r>idx) return colStr + (r);
      }
    }else{
      if(delta===1){
        if(c>=idx) return indexToCol(c+delta) + rowStr;
      }else{
        if(c===idx) return '#REF!';
        if(c>idx) return indexToCol(c+delta) + rowStr;
      }
    }
    return match;
  });
}
function rewriteAllFormulas(type, idx, delta){
  for(let r=0;r<ROWS;r++) for(let c=0;c<COLS;c++){
    if(data[r][c].raw.includes('=')){
      data[r][c].raw = rewriteFormula(data[r][c].raw, type, idx, delta);
    }
  }
}

/* ============================================================
   GRID RENDERING
   ============================================================ */
const gridEl=document.getElementById('grid');
function renderGrid(){
  let html='<thead><tr><th class="corner"></th>';
  for(let c=0;c<COLS;c++) html+=`<th class="colHead" data-col="${c}">${indexToCol(c)}</th>`;
  html+='</tr></thead><tbody>';
  for(let r=0;r<ROWS;r++){
    html+=`<tr><th class="rowHead" data-row="${r}">${r+1}</th>`;
    for(let c=0;c<COLS;c++){
      const comp=computed[r][c];
      let cls='';
      if(comp.error) cls='error';
      else if(comp.isNumber) cls='numeric';
      else if(comp.display!=='') cls='text';
      const sel = (r===selRow && c===selCol) ? ' selected active' : '';
      const disp = comp.display.replace(/</g,'&lt;');
      html+=`<td data-r="${r}" data-c="${c}" class="${cls}${sel}"><div class="cellContent">${disp}</div></td>`;
    }
    html+='</tr>';
  }
  html+='</tbody>';
  gridEl.innerHTML=html;
  document.getElementById('addrBox').textContent=posToAddr(selRow,selCol);
  document.getElementById('formulaInput').value=data[selRow][selCol].raw;
  attachCellEvents();
}

function attachCellEvents(){
  gridEl.querySelectorAll('td').forEach(td=>{
    td.addEventListener('click', onCellClick);
    td.addEventListener('dblclick', onCellDblClick);
  });
  gridEl.querySelectorAll('th.colHead').forEach(th=>{
    th.addEventListener('contextmenu', e=>{ e.preventDefault(); openCtx(e,'col', parseInt(th.dataset.col)); });
    th.addEventListener('click', ()=> selectCol(parseInt(th.dataset.col)));
  });
  gridEl.querySelectorAll('th.rowHead').forEach(th=>{
    th.addEventListener('contextmenu', e=>{ e.preventDefault(); openCtx(e,'row', parseInt(th.dataset.row)); });
    th.addEventListener('click', ()=> selectRow(parseInt(th.dataset.row)));
  });
}
function selectCol(c){ selCol=c; selRow=0; if(editing) commitEdit(); renderGrid(); }
function selectRow(r){ selRow=r; selCol=0; if(editing) commitEdit(); renderGrid(); }

/* ============================================================
   SELECTION & EDITING
   ============================================================ */
function onCellClick(e){
  const td=e.currentTarget;
  const r=parseInt(td.dataset.r), c=parseInt(td.dataset.c);
  if(editing) commitEdit();
  selRow=r; selCol=c;
  renderGrid();
  document.getElementById('formulaInput').focus();
}
function onCellDblClick(e){
  const td=e.currentTarget;
  const r=parseInt(td.dataset.r), c=parseInt(td.dataset.c);
  selRow=r; selCol=c;
  startEdit(td);
}
function startEdit(td){
  if(editing) return;
  editing=true;
  const r=selRow,c=selCol;
  const raw=data[r][c].raw;
  // create editor
  const inp=document.createElement('input');
  inp.id='cellEditor';
  inp.value=raw;
  inp.addEventListener('keydown', onEditorKey);
  inp.addEventListener('blur', ()=> commitEdit());
  td.appendChild(inp);
  inp.focus();
  inp.select();
  editorEl=inp;
  // sync formula bar
  document.getElementById('formulaInput').value=raw;
}
function commitEdit(){
  if(!editing || !editorEl) return;
  const val=editorEl.value;
  editing=false;
  editorEl.remove();
  editorEl=null;
  setCellRaw(selRow, selCol, val);
}
function cancelEdit(){
  if(!editing) return;
  editing=false;
  if(editorEl) editorEl.remove();
  editorEl=null;
  renderGrid();
}
function setCellRaw(r,c,raw){
  data[r][c].raw=raw;
  recalc();
  save();
  renderGrid();
}

/* formula bar */
const formulaInput=document.getElementById('formulaInput');
formulaInput.addEventListener('focus', ()=>{
  // if not editing, show raw
  formulaInput.value=data[selRow][selCol].raw;
});
formulaInput.addEventListener('input', ()=>{
  // live sync without commit? update data but debounce recalc
  data[selRow][selCol].raw=formulaInput.value;
  recalc();
  // update grid display without full re-render? do full for simplicity
  // avoid losing focus
  const selTd = gridEl.querySelector(`td[data-r="${selRow}"][data-c="${selCol}"] .cellContent`);
  if(selTd){
    const comp=computed[selRow][selCol];
    selTd.textContent=comp.display;
    const td=selTd.parentElement;
    td.className = (comp.error?'error':comp.isNumber?'numeric':'text') + ' selected active';
  }
});
formulaInput.addEventListener('keydown', e=>{
  if(e.key==='Enter'){
    e.preventDefault();
    data[selRow][selCol].raw=formulaInput.value;
    recalc(); save();
    // move down
    if(selRow<ROWS-1) selRow++;
    renderGrid();
    formulaInput.focus();
  }else if(e.key==='Tab'){
    e.preventDefault();
    data[selRow][selCol].raw=formulaInput.value;
    recalc(); save();
    if(selCol<COLS-1) selCol++; else if(selRow<ROWS-1){ selCol=0; selRow++; }
    renderGrid();
    formulaInput.focus();
  }else if(e.key==='Escape'){
    formulaInput.value=data[selRow][selCol].raw;
    formulaInput.blur();
  }
});
formulaInput.addEventListener('blur', ()=>{
  data[selRow][selCol].raw=formulaInput.value;
  recalc(); save(); renderGrid();
});

function onEditorKey(e){
  if(e.key==='Enter'){
    e.preventDefault();
    const val=e.target.value;
    editing=false;
    e.target.remove(); editorEl=null;
    data[selRow][selCol].raw=val;
    recalc(); save();
    if(selRow<ROWS-1) selRow++;
    renderGrid();
  }else if(e.key==='Tab'){
    e.preventDefault();
    const val=e.target.value;
    editing=false;
    e.target.remove(); editorEl=null;
    data[selRow][selCol].raw=val;
    recalc(); save();
    if(selCol<COLS-1) selCol++; else if(selRow<ROWS-1){ selCol=0; selRow++; }
    renderGrid();
  }else if(e.key==='Escape'){
    cancelEdit();
  }
}

/* keyboard navigation */
document.addEventListener('keydown', e=>{
  if(editing) return;
  if(e.target===formulaInput) return;
  if(e.key==='Delete' || e.key==='Backspace'){
    if(document.activeElement.tagName==='INPUT') return;
    e.preventDefault();
    data[selRow][selCol].raw='';
    recalc(); save(); renderGrid();
    return;
  }
  let moved=false;
  if(e.key==='ArrowUp'){ if(selRow>0){ selRow--; moved=true; } e.preventDefault();}
  else if(e.key==='ArrowDown'){ if(selRow<ROWS-1){ selRow++; moved=true; } e.preventDefault();}
  else if(e.key==='ArrowLeft'){ if(selCol>0){ selCol--; moved=true; } e.preventDefault();}
  else if(e.key==='ArrowRight'){ if(selCol<COLS-1){ selCol++; moved=true; } e.preventDefault();}
  else if(e.key==='Enter'){ if(selRow<ROWS-1) selRow++; else selRow=0; moved=true; e.preventDefault();}
  else if(e.key==='Tab'){ if(selCol<COLS-1) selCol++; else { selCol=0; if(selRow<ROWS-1) selRow++; } moved=true; e.preventDefault();}
  else if(e.key.length===1 && !e.ctrlKey && !e.metaKey && !e.altKey){
    // start typing
    const td=gridEl.querySelector(`td[data-r="${selRow}"][data-c="${selCol}"]`);
    if(td){ startEdit(td); setTimeout(()=>{ if(editorEl){ editorEl.value=e.key; editorEl.setSelectionRange(1,1); formulaInput.value=e.key; } },0); }
    return;
  }
  if(moved){
    renderGrid();
    document.getElementById('formulaInput').value=data[selRow][selCol].raw;
    document.getElementById('addrBox').textContent=posToAddr(selRow,selCol);
  }
});

/* ============================================================
   ROW/COL OPERATIONS
   ============================================================ */
function insertRow(at){
  if(ROWS>=200) return;
  // rewrite formulas first? need to rewrite before shifting data so refs adjust correctly
  rewriteAllFormulas('row', at, 1);
  data.splice(at,0, Array.from({length:COLS}, ()=> ({raw:''})));
  computed.splice(at,0, Array.from({length:COLS}, ()=> ({display:'', error:null, isNumber:false})));
  ROWS++;
  if(selRow>=at) selRow++;
  recalc(); save(); renderGrid();
}
function deleteRow(at){
  if(ROWS<=1) return;
  // rewrite formulas: refs to deleted row become #REF!
  rewriteAllFormulas('row', at, -1);
  data.splice(at,1);
  computed.splice(at,1);
  ROWS--;
  if(selRow>=ROWS) selRow=ROWS-1;
  if(selRow>=at && selRow>0) {} // keep
  recalc(); save(); renderGrid();
}
function insertCol(at){
  if(COLS>=52) return;
  rewriteAllFormulas('col', at, 1);
  for(let r=0;r<ROWS;r++){
    data[r].splice(at,0,{raw:''});
    computed[r].splice(at,0,{display:'', error:null, isNumber:false});
  }
  COLS++;
  if(selCol>=at) selCol++;
  recalc(); save(); renderGrid();
}
function deleteCol(at){
  if(COLS<=1) return;
  rewriteAllFormulas('col', at, -1);
  for(let r=0;r<ROWS;r++){
    data[r].splice(at,1);
    computed[r].splice(at,1);
  }
  COLS--;
  if(selCol>=COLS) selCol=COLS-1;
  recalc(); save(); renderGrid();
}

/* toolbar buttons */
document.getElementById('btnInsertRowAbove').addEventListener('click', ()=> insertRow(selRow));
document.getElementById('btnInsertRowBelow').addEventListener('click', ()=> insertRow(selRow+1));
document.getElementById('btnDeleteRow').addEventListener('click', ()=> deleteRow(selRow));
document.getElementById('btnInsertColLeft').addEventListener('click', ()=> insertCol(selCol));
document.getElementById('btnInsertColRight').addEventListener('click', ()=> insertCol(selCol+1));
document.getElementById('btnDeleteCol').addEventListener('click', ()=> deleteCol(selCol));
document.getElementById('btnClear').addEventListener('click', ()=>{
  data[selRow][selCol].raw=''; recalc(); save(); renderGrid();
});
document.getElementById('btnClearAll').addEventListener('click', ()=>{
  if(confirm('Clear entire sheet?')){
    initData(); ROWS=CONFIG.ROWS; COLS=CONFIG.COLS; initData(); selRow=0; selCol=0; recalc(); save(); renderGrid();
  }
});

/* context menu */
const ctxMenu=document.getElementById('ctxMenu');
function openCtx(e,type,idx){
  ctxTarget={type,idx};
  ctxMenu.innerHTML='';
  if(type==='row'){
    ctxMenu.innerHTML=`
      <button data-a="insAbove">Insert row above</button>
      <button data-a="insBelow">Insert row below</button>
      <button data-a="del">Delete row ${idx+1}</button>
    `;
  }else{
    ctxMenu.innerHTML=`
      <button data-a="insLeft">Insert column left</button>
      <button data-a="insRight">Insert column right</button>
      <button data-a="del">Delete column ${indexToCol(idx)}</button>
    `;
  }
  ctxMenu.style.display='block';
  ctxMenu.style.left=e.clientX+'px';
  ctxMenu.style.top=e.clientY+'px';
  ctxMenu.querySelectorAll('button').forEach(b=>{
    b.addEventListener('click', ()=>{
      const a=b.dataset.a;
      if(ctxTarget.type==='row'){
        if(a==='insAbove') insertRow(ctxTarget.idx);
        if(a==='insBelow') insertRow(ctxTarget.idx+1);
        if(a==='del') deleteRow(ctxTarget.idx);
      }else{
        if(a==='insLeft') insertCol(ctxTarget.idx);
        if(a==='insRight') insertCol(ctxTarget.idx+1);
        if(a==='del') deleteCol(ctxTarget.idx);
      }
      ctxMenu.style.display='none';
    });
  });
}
document.addEventListener('click', e=>{ if(!ctxMenu.contains(e.target)) ctxMenu.style.display='none'; });

/* ============================================================
   INIT
   ============================================================ */
(function init(){
  const hasData=load();
  if(!hasData) initData();
  // ensure computed size matches
  computed = Array.from({length:ROWS}, (_,r)=> Array.from({length:COLS}, (_,c)=> ({display:'', error:null, isNumber:false})));
  if(!hasData){
    // demo data for self-check hint
    // leave empty but ensure recalc
  }
  recalc();
  renderGrid();
  // expose for probe tests
  window._sheet = { data, computed, setCell:(addr, raw)=>{ const p=addrToPos(addr); if(p){ data[p.row][p.col].raw=raw; recalc(); save(); renderGrid(); }}, getDisplay:(addr)=>{ const p=addrToPos(addr); return p? computed[p.row][p.col].display : null; }, getRaw:(addr)=>{ const p=addrToPos(addr); return p? data[p.row][p.col].raw : null; } };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"7dad701b-96b0-4d68-b8a6-6ef0227e0e41","tokensIn":265121,"tokensOut":19784,"tokensTotal":284905,"cost":0.015584659999999998,"turns":13,"toolCalls":12,"failedToolCalls":0,"timestamp":"2026-08-23T00:50:15.677Z"} -->