Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion fastapi_react/backend/app/services/reference_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3124,7 +3124,13 @@ def _is_number(x):
ui.image(str(mae_img), width=1000)
except Exception:
pass
ui.info('\n **Color scale**: darker/warmer colors indicate larger average absolute error.\n\n **Missing cells**: blank or neutral color means insufficient data (no races for that pair).\n\n **Sample size**: confidence intervals are empirical percentiles computed only when a group has at least 5 residuals.\n \n **Interpretation**: cells with darker colors indicate that the model has higher prediction errors for that driver/constructor at that circuit, suggesting potential areas for model improvement or unique performance characteristics.\n ')
ui.info(
'**How to read these heatmaps**\n\n'
'- **Color:** Darker, warmer cells indicate higher average absolute error.\n'
'- **Blank cells:** There were not enough races for that driver or constructor at that circuit.\n'
'- **Sample size:** Each cell averages the available races; confidence intervals are shown only for groups with at least 5 residuals.\n'
'- **Interpretation:** Treat higher-error cells as leads for further investigation. They can reflect model weaknesses or unusual race conditions.'
)
for img_name, title in [('heatmap_driver_by_circuit.png', 'Driver x Circuit heatmap'), ('heatmap_constructor_by_circuit.png', 'Constructor x Circuit heatmap')]:
img_path = OUT_DIR / img_name
if img_path.exists():
Expand Down
2 changes: 1 addition & 1 deletion fastapi_react/frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export default function App() {
</div>
{sidebar && <aside className="filter-sidebar" aria-label="Data filters"><div className="view-flow"><ViewNodes nodes={data?.sidebar} values={values} change={change} action={act} /></div></aside>}
<div className="main-shell">
{FEATURES_ENABLED && <details><summary>Analysis tools</summary><FeatureBar page={page} values={values} options={options} setOptions={setOptions} restore={restore} navigate={navigate} busy={busy || Boolean(error) || data?.page !== page} analysisRevision={data?.page === page ? data.source_revision : null}/></details>}
<header className="parity-header">
<img src="/api/brand/logo" alt="Gridlocked" width="450" height="264" />
{shell.length ? <div className="view-flow shell-copy"><ViewNodes nodes={shell} /></div> : <h1 className="shell-title">F1 Races from 2016 to {new Date().getFullYear()}</h1>}
Expand All @@ -154,6 +153,7 @@ export default function App() {
{busy && !FEATURES_ENABLED && <span className="sr-only" role="status">Loading analysis…</span>}
<footer className="parity-footer"><p>Powered by <a href="https://www.betting-oracle.com" target="_blank" rel="noreferrer">Betting Oracle</a></p><p>Sports Prediction Analytics</p><a href="https://www.betting-oracle.com" target="_blank" rel="noreferrer"><picture><source type="image/webp" srcSet="/betting-oracle-logo-60.webp 1x, /betting-oracle-logo-120.webp 2x"/><img src="/betting-oracle-logo.png" alt="Betting Oracle Logo" width="822" height="1255" style={{width: 60 * 822 / 1255}} loading="lazy" decoding="async" /></picture></a></footer>
</main>
{FEATURES_ENABLED && <details className="analysis-tools"><summary>Analysis tools</summary><FeatureBar page={page} values={values} options={options} setOptions={setOptions} restore={restore} navigate={navigate} busy={busy || Boolean(error) || data?.page !== page} analysisRevision={data?.page === page ? data.source_revision : null}/></details>}
</div>
</div>;
}
2 changes: 1 addition & 1 deletion fastapi_react/frontend/src/components/Presentation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export function ViewNodes({ nodes = [], values = {}, change = (_key, _value) =>
case 'json': return <pre key={key} className="view-json">{JSON.stringify(node.value, null, 2)}</pre>;
case 'notice':
if (node.text === 'Research controls are disabled in hosted mode. Enable F1_RESEARCH_MODE=1 only for a trusted local/admin session; precomputed analyses remain available below.') return null;
return <div key={key} className={`view-notice ${node.severity}`} role={node.severity === 'error' ? 'alert' : 'status'}>{node.icon && <span>{node.icon}</span>}<Markdown>{node.text}</Markdown></div>;
return <div key={key} className={`view-notice ${node.severity}`} role={node.severity === 'error' ? 'alert' : 'status'}>{node.icon && <span>{node.icon}</span>}<div className="view-notice-content"><Markdown>{node.text}</Markdown></div></div>;
case 'metric': return <div key={key} className="view-metric"><span>{node.label}</span><strong>{node.value}</strong>{node.delta != null && <small>{node.delta}</small>}</div>;
case 'divider': return <hr key={key} className="view-divider" />;
case 'image': return <img key={key} alt={node.alt || 'Analysis visualization'} src={node.src} className="view-image" style={{width: node.width === 'stretch' ? '100%' : node.width, maxWidth: '100%'}} />;
Expand Down
28 changes: 23 additions & 5 deletions fastapi_react/frontend/src/components/ViewTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import {displayCell} from './Presentation';
import {useTheme} from './useTheme';

const empty=[];
const DEFAULT_COLUMN_WIDTH=150;
const estimateColumnWidth=(column,index,rows)=>{
const longest=rows.reduce((max,row)=>Math.max(max,String(row[index]??'').length),String(column.label??'').length);
const minimum=column.kind==='NumberColumn'?72:96;
return Math.min(320,Math.max(minimum,Math.ceil(longest*6+24)));
};
const quote=value=>{const text=value==null?'':String(value);return /[,"\r\n]/.test(text)?`"${text.replaceAll('"','""')}"`:text;};
function csvValue(value,column){
if(value==null)return '';
Expand Down Expand Up @@ -32,6 +38,16 @@ export function ViewTable({node}) {
const [pinned,setPinned]=useState([]);
const [formats,setFormats]=useState(/** @type {Record<string,string>} */ ({}));
const outer=useRef(null);
const [availableWidth,setAvailableWidth]=useState(0);
useEffect(()=>{
const parent=outer.current?.parentElement;
if(!parent)return;
const measure=()=>setAvailableWidth(parent.clientWidth);
measure();
const observer=new ResizeObserver(measure);
observer.observe(parent);
return()=>observer.disconnect();
},[]);
useEffect(()=>{
if(!menu && !showColumns)return;
const close=event=>{if(event.type==='keydown' && event.key==='Escape' || event.type==='pointerdown' && !outer.current?.contains(event.target)){setMenu(null);setShowColumns(false);}};
Expand All @@ -50,10 +66,12 @@ export function ViewTable({node}) {
},[rows,sort]);
const visible=useMemo(()=>columns.map((column,index)=>({column,index})).filter(c=>!hidden.includes(c.index)).sort((a,b)=>Number(pinned.includes(b.index))-Number(pinned.includes(a.index))),[columns,hidden,pinned]);
const gridColumns=useMemo(()=>{
const result=visible.map(({column,index})=>({id:String(index),title:column.label+(sort?.column===index?(sort.desc?' ↓':' ↑'):''),hasMenu:true,width:widths[index] || (typeof column.width==='number'?column.width:undefined)}));
if(!node.hide_index)result.unshift({id:'index',title:node.index_name || '',width:widths.index});
const result=visible.map(({column,index})=>({id:String(index),title:column.label+(sort?.column===index?(sort.desc?' ↓':' ↑'):''),hasMenu:true,width:widths[index] || Math.max(typeof column.width==='number'?column.width:0,estimateColumnWidth(column,index,rows))}));
if(!node.hide_index)result.unshift({id:'index',title:node.index_name || '',width:widths.index || 80});
return result;
},[visible,widths,sort,node.hide_index,node.index_name]);
},[visible,widths,sort,node.hide_index,node.index_name,rows]);
const contentWidth=gridColumns.reduce((total,column)=>total+(widths[column.id] || column.width || DEFAULT_COLUMN_WIDTH),0);
const tableWidth=Math.min(contentWidth,availableWidth || Infinity,typeof node.width==='number'?node.width:Infinity);
const dark=useTheme()==='dark';
const bg=dark?'#0e1117':'#fff',text=dark?'#fafafa':'#31333f';
/** @type {(cell: import('@glideapps/glide-data-grid').Item) => import('@glideapps/glide-data-grid').GridCell} */
Expand Down Expand Up @@ -86,7 +104,7 @@ export function ViewTable({node}) {
const height=node.height || Math.min(400,(rows.length+1)*35+3);
const menuColumn=menu?columns[menu.index]:null;
const numeric=menuColumn?.kind==='NumberColumn';
return <div className="view-table canvas-table" ref={outer} style={{maxWidth:typeof node.width==='number'?node.width:undefined}}>
return <div className="view-table canvas-table" ref={outer} style={{width:tableWidth,maxWidth:'100%'}}>
<div className="table-toolbar">
<button aria-label="Search table" title="Search" onClick={()=>setShowSearch(s=>!s)}>⌕</button>
<button aria-label="Show or hide columns" title="Columns" onClick={()=>setShowColumns(s=>!s)}>▥</button>
Expand All @@ -95,6 +113,6 @@ export function ViewTable({node}) {
</div>
{showColumns && <div className="column-picker">{columns.map((col,i)=><label key={i}><input type="checkbox" checked={!hidden.includes(i)} onChange={()=>setHidden(h=>h.includes(i)?h.filter(x=>x!==i):[...h,i])}/>{col.label}</label>)}</div>}
{menuColumn && <div className="grid-column-menu" role="group" aria-label={`${menuColumn.label} column options`} style={{left:menu.left}}><strong>{menuColumn.label}</strong><button onClick={()=>{setSort({column:menu.index,desc:false});setMenu(null);}}>Sort ascending</button><button onClick={()=>{setSort({column:menu.index,desc:true});setMenu(null);}}>Sort descending</button><button onClick={()=>{setSort(null);setMenu(null);}}>Clear sorting</button><button onClick={()=>{setPinned(p=>p.includes(menu.index)?p.filter(i=>i!==menu.index):[...p,menu.index]);setMenu(null);}}>{pinned.includes(menu.index)?'Unpin column':'Pin column'}</button><button onClick={()=>{setHidden(h=>[...h,menu.index]);setMenu(null);}}>Hide column</button>{numeric && <label>Number format<select value={formats[menu.index] || ''} onChange={event=>setFormats(f=>({...f,[menu.index]:event.target.value}))}><option value="">Default</option><option value="%d">Integer</option>{[1,2,3,4].map(n=><option key={n} value={`%.${n}f`}>{n} decimal places</option>)}<option value="percent">Percent</option></select></label>}<small>{rows.length.toLocaleString()} rows · {rows.filter(r=>r[menu.index]==null).length.toLocaleString()} missing · {new Set(rows.map(r=>r[menu.index])).size.toLocaleString()} unique</small></div>}
<DataEditor columns={gridColumns} rows={rows.length} getCellContent={getCell} getCellsForSelection={true} width="100%" height={height} rowHeight={35} headerHeight={35} rowMarkers="none" minColumnWidth={50} maxColumnWidth={500} freezeColumns={pinned.length+(node.hide_index?0:1)} showSearch={showSearch} onSearchClose={()=>setShowSearch(false)} onHeaderMenuClick={(col,bounds)=>{const selected=visible[col-(node.hide_index?0:1)];if(selected)setMenu({index:selected.index,left:Math.max(0,Math.min(bounds.x-(outer.current?.getBoundingClientRect().x || 0),(outer.current?.clientWidth || 240)-240))});}} onColumnResize={(column,width)=>setWidths(old=>({...old,[column.id]:width}))} onColumnResizeEnd={(column,width)=>setWidths(old=>({...old,[column.id]:width}))} onHeaderClicked={col=>{const selected=visible[col-(node.hide_index?0:1)];if(selected)setSort(s=>s?.column===selected.index?(s.desc?null:{...s,desc:true}):{column:selected.index,desc:false});}} theme={{fontFamily:'Source Sans',baseFontStyle:'13px',headerFontStyle:'13px',cellHorizontalPadding:8,cellVerticalPadding:3,bgCell:bg,bgHeader:dark?'#262730':'#f7f9fc',bgHeaderHovered:dark?'#3a3d46':'#eff1f6',bgHeaderHasFocus:dark?'#3a3d46':'#eff1f6',textDark:text,textHeader:dark?'#bfc2ce':'#808495',textMedium:text,textLight:'#808495',borderColor:dark?'#3a3d46':'#e6e7eb',accentColor:'#ff4b4b',accentLight:dark?'#ff4b4b33':'#ff4b4b1a',accentFg:'#fff',headerBottomBorderColor:dark?'#3a3d46':'#d6d8df',roundingRadius:0}} />
<DataEditor columns={gridColumns} rows={rows.length} getCellContent={getCell} getCellsForSelection={true} width={tableWidth} height={height} rowHeight={35} headerHeight={35} rowMarkers="none" minColumnWidth={50} maxColumnWidth={500} freezeColumns={pinned.length+(node.hide_index?0:1)} showSearch={showSearch} onSearchClose={()=>setShowSearch(false)} onHeaderMenuClick={(col,bounds)=>{const selected=visible[col-(node.hide_index?0:1)];if(selected)setMenu({index:selected.index,left:Math.max(0,Math.min(bounds.x-(outer.current?.getBoundingClientRect().x || 0),(outer.current?.clientWidth || 240)-240))});}} onColumnResize={(column,width)=>setWidths(old=>({...old,[column.id]:width}))} onColumnResizeEnd={(column,width)=>setWidths(old=>({...old,[column.id]:width}))} onHeaderClicked={col=>{const selected=visible[col-(node.hide_index?0:1)];if(selected)setSort(s=>s?.column===selected.index?(s.desc?null:{...s,desc:true}):{column:selected.index,desc:false});}} theme={{fontFamily:'Source Sans',baseFontStyle:'13px',headerFontStyle:'13px',cellHorizontalPadding:8,cellVerticalPadding:3,bgCell:bg,bgHeader:dark?'#262730':'#f7f9fc',bgHeaderHovered:dark?'#3a3d46':'#eff1f6',bgHeaderHasFocus:dark?'#3a3d46':'#eff1f6',textDark:text,textHeader:dark?'#bfc2ce':'#555965',textMedium:text,textLight:'#808495',borderColor:dark?'#3a3d46':'#e6e7eb',accentColor:'#ff4b4b',accentLight:dark?'#ff4b4b33':'#ff4b4b1a',accentFg:'#fff',headerBottomBorderColor:dark?'#3a3d46':'#d6d8df',roundingRadius:0}} />
</div>;
}
12 changes: 9 additions & 3 deletions fastapi_react/frontend/src/parity.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ main:focus{outline:none}
.shell-copy{margin-top:16px}
.view-flow{display:flex;flex-direction:column;gap:16px;min-width:0}
.view-flow:empty{display:none}
.analysis-tools{margin-top:24px;padding-top:12px;border-top:1px solid var(--border)}
.analysis-tools summary{cursor:pointer;color:var(--muted);font-size:14px;padding:6px 0}
.view-heading{font-weight:600;letter-spacing:0;line-height:1.2;margin:0 0 -16px;padding:16px 0}
h1.view-heading,.shell-title{font-size:44px;font-weight:700;padding:20px 0 16px;line-height:1.2}
h2.view-heading{font-size:36px}
Expand Down Expand Up @@ -54,8 +56,12 @@ h3.view-heading{font-size:28px}
.number-input button{width:32px;flex-shrink:0;padding:0!important;border:0!important;border-radius:0;font-size:22px;background:transparent!important}
.number-input button:hover{background:#e4e6ec!important}
.view-notice{display:flex;gap:16px;padding:16px;border-radius:8px;background:#e8f2fc;color:#17517d;font-size:16px;line-height:25.6px}
.view-notice p{margin:0}
.view-notice.info{background:#e8f2fc;color:#17517d}
.view-notice-content{flex:1;min-width:0}
.view-notice-content p{margin:0 0 8px}
.view-notice-content p:last-child{margin-bottom:0}
.view-notice-content ul,.view-notice-content ol{margin:4px 0 0;padding-left:22px}
.view-notice-content li+li{margin-top:4px}
.view-notice.info{align-items:flex-start;border:1px solid #c8ddf0;border-left:4px solid #5799ce;background:#f2f7fc;color:#17436a;padding:12px 16px}
.view-notice.warning{background:#fff8e1;color:#715000}
.view-notice.error{background:#ffeded;color:#922222}
.view-notice.success{background:#e5f7ed;color:#17683b}
Expand Down Expand Up @@ -148,7 +154,7 @@ h3.view-heading{font-size:28px}
.view-help{float:right;border:1px solid #808495;border-radius:50%;font-size:10px;font-weight:600;width:14px;height:14px;line-height:12px;text-align:center;margin-top:4px;color:#808495}.upload-file{padding:8px 16px;display:flex;justify-content:space-between}.upload-file button,.multiselect-box button{border:0;font-size:18px;line-height:1}.multiselect-field{position:relative}.multiselect-box{display:flex;flex-wrap:wrap;gap:8px;background:#f0f2f6;border-radius:8px;padding:8px 12px;min-height:40px}.select-tag{display:flex;align-items:center;gap:6px;background:#ff4b4b;color:white;padding:0 6px;border-radius:4px;font-size:14px;line-height:24px}.select-tag button{color:white}.multiselect-box input{min-width:40px;flex:1;width:40px;background:transparent;border:0;outline:none}.multiselect-options{position:absolute;top:100%;left:0;right:0;background:var(--page-bg);border:1px solid var(--border);box-shadow:0 4px 16px #0002;border-radius:8px;padding:8px;z-index:20;max-height:300px;overflow:auto}.multiselect-options button{display:block;width:100%;border:0;text-align:left;padding:8px;border-radius:4px}.multiselect-options button:hover{background:#f0f2f6}:root[data-theme=dark] .multiselect-box{background:#262730}

.parity-nav,.tab-strip{position:relative}.tab-scroll{position:absolute!important;top:0!important;bottom:2px!important;width:20px!important;height:38px!important;z-index:4!important;border:0!important;background:var(--page-bg)!important;color:#808495!important;font-size:24px!important;line-height:38px!important;padding:0!important}.tab-scroll.left{left:0}.tab-scroll.right{right:0}.view-checkbox>span{font-size:14px;line-height:21px}.view-field>label,.view-field>span{display:flex;align-items:center;justify-content:space-between;min-height:24px}.view-help{margin-top:0}h3.view-heading{padding-top:12px}
.view-caption{color:inherit;opacity:.6}.chart-shell{position:relative;min-width:0}.chart-shell:hover>.table-toolbar,.chart-shell:focus-within>.table-toolbar{opacity:1}.chart-shell>.table-toolbar{top:0}.chart-shell:fullscreen{padding:40px;background:var(--page-bg)}.chart-shell:fullscreen .view-chart{height:calc(100vh - 80px)}
.view-caption{color:inherit}.chart-shell{position:relative;min-width:0}.chart-shell:hover>.table-toolbar,.chart-shell:focus-within>.table-toolbar{opacity:1}.chart-shell>.table-toolbar{top:0}.chart-shell:fullscreen{padding:40px;background:var(--page-bg)}.chart-shell:fullscreen .view-chart{height:calc(100vh - 80px)}
.view-slider>label{position:relative;top:1px}.canvas-table .column-picker{top:30px}
.grid-column-menu{position:absolute;top:35px;z-index:10;width:240px;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--page-bg);box-shadow:0 4px 16px #0002;font-size:14px;display:flex;flex-direction:column;gap:4px}.grid-column-menu>button{text-align:left;padding:8px;border:0;border-radius:4px;font-size:14px}.grid-column-menu>button:hover{background:#f0f2f6}.grid-column-menu strong,.grid-column-menu small{padding:4px 8px}.grid-column-menu label{display:flex;flex-direction:column;padding:8px;gap:4px}.grid-column-menu select{padding:6px;border:1px solid var(--border);border-radius:4px}
.table-toolbar{pointer-events:none}.view-table:hover>.table-toolbar,.view-table:focus-within>.table-toolbar,.chart-shell:hover>.table-toolbar,.chart-shell:focus-within>.table-toolbar{pointer-events:auto}.chart-shell>.table-toolbar{z-index:6}
Expand Down
14 changes: 5 additions & 9 deletions raceAnalysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -6426,15 +6426,11 @@ def _is_number(x):

# Notes (render in a shaded info box)
st.info(
"""
**Color scale**: darker/warmer colors indicate larger average absolute error.

**Missing cells**: blank or neutral color means insufficient data (no races for that pair).

**Sample size**: confidence intervals are empirical percentiles computed only when a group has at least 5 residuals.

**Interpretation**: cells with darker colors indicate that the model has higher prediction errors for that driver/constructor at that circuit, suggesting potential areas for model improvement or unique performance characteristics.
"""
"**How to read these heatmaps**\n\n"
"- **Color:** Darker, warmer cells indicate higher average absolute error.\n"
"- **Blank cells:** There were not enough races for that driver or constructor at that circuit.\n"
"- **Sample size:** Each cell averages the available races; confidence intervals are shown only for groups with at least 5 residuals.\n"
"- **Interpretation:** Treat higher-error cells as leads for further investigation. They can reflect model weaknesses or unusual race conditions."
)

# Heatmaps
Expand Down
Loading