diff --git a/public/app.js b/public/app.js
index 0a9c4b6..4a094a8 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,7 +1,17 @@
const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), fmt2=n=>new Intl.NumberFormat('en',{notation:'compact',minimumFractionDigits:2,maximumFractionDigits:2}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`;
let data, chartMode='models';
const palette=['#7357ff','#20a4a8','#ff805d','#e4b63f','#4b8cff','#d45bba','#72a33a','#9b6b43','#32343a','#a5a2ff','#19b875','#e85d5d'];
-const modelColor=name=>{let hash=2166136261;for(const char of String(name))hash=Math.imul(hash^char.codePointAt(0),16777619);return palette[(hash>>>0)%palette.length]};
+const modelColors=new Map(),colorRgb=color=>color.match(/\w\w/g).map(x=>parseInt(x,16)),colorDistance=(a,b)=>Math.hypot(...colorRgb(a).map((x,i)=>x-colorRgb(b)[i]));
+function colorHash(name){let hash=2166136261;for(const char of String(name))hash=Math.imul(hash^char.codePointAt(0),16777619);return hash>>>0}
+function syncModelColors(names){
+ for(const name of [...new Set(names)].sort()){
+ if(modelColors.has(name))continue;
+ const used=[...modelColors.values()],start=colorHash(name)%palette.length,candidates=palette.map((_,i)=>palette[(start+i)%palette.length]).filter(color=>!used.includes(color));
+ const distance=color=>used.length?Math.min(...used.map(other=>colorDistance(color,other))):Infinity;
+ modelColors.set(name,candidates.find(color=>distance(color)>=90)||candidates.sort((a,b)=>distance(b)-distance(a))[0]||palette[start]);
+ }
+}
+const modelColor=name=>{syncModelColors([name]);return modelColors.get(name)};
async function load(){
const q=new URLSearchParams(); for(const id of ['from','to','project','model','source']) if($('#'+id).value) q.set(id,$('#'+id).value);
$('#subtitle').textContent='Reading local session history…';
@@ -10,7 +20,7 @@ async function load(){
function options(el,values,label){const old=el.value;el.innerHTML=``+values.map(x=>``).join('');el.value=old}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
function render(){
- const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');options($('#source'),data.filters.sources||[],'sources');
+ const {total:t,meta}=data;syncModelColors(data.filters.models);options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');options($('#source'),data.filters.sources||[],'sources');
$('#subtitle').textContent=`${meta.files} sessions · ${meta.events.toLocaleString()} model calls · scanned ${new Date(meta.scannedAt).toLocaleTimeString()}`;
$('#cost').textContent=money(t.cost);const coverage=t.total?t.priced/t.total*100:0;$('#coverage').textContent=`${pct(coverage)} token pricing coverage · standard API rates`;
const uncached=Math.max(0,t.input-t.cached);