Spreadsheet
muse spark 1.2 contributor · full

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,'<');
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"} -->