335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
// Section.jsx — OTIVM-IV
|
||
// Generic panel renderer. Receives a section definition and renders
|
||
// the appropriate sub-component based on the "type" field.
|
||
// Does not know what it is showing — delegates entirely to type.
|
||
//
|
||
// Section definition shape (from context-{id}.json):
|
||
// title — string, shown in panel header
|
||
// type — one of the section types below
|
||
// col — layout column assignment (read by Shell.jsx LayoutGrid)
|
||
// [data] — type-specific data array or object
|
||
//
|
||
// Section types:
|
||
// status-block — key/value pairs
|
||
// parameter-list — actor_parameters rows
|
||
// auctoritas — three-face auctoritas panel
|
||
// cost-table — cost items with amounts and sources
|
||
// drift-log — parameter_drift_log entries
|
||
// action-bar — action buttons
|
||
// route-list — trade route cards with cost breakdown
|
||
// text-block — narrative text, optionally collapsible
|
||
// map-canvas — TESSERA fog-of-war map placeholder
|
||
//
|
||
// The "col" prop is consumed by Shell.jsx LayoutGrid for column placement.
|
||
// Section.jsx itself ignores it.
|
||
|
||
import { useState } from 'react'
|
||
|
||
export default function Section({ title, type, col, ...props }) {
|
||
return (
|
||
<div className="otivm-section">
|
||
<div className="otivm-section-head">
|
||
<span className="otivm-section-title">{title}</span>
|
||
</div>
|
||
<div className="otivm-section-body">
|
||
<SectionBody type={type} {...props} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionBody({ type, ...props }) {
|
||
switch (type) {
|
||
case 'status-block': return <StatusBlock {...props} />
|
||
case 'parameter-list': return <ParameterList {...props} />
|
||
case 'auctoritas': return <Auctoritas {...props} />
|
||
case 'cost-table': return <CostTable {...props} />
|
||
case 'drift-log': return <DriftLog {...props} />
|
||
case 'action-bar': return <ActionBar {...props} />
|
||
case 'route-list': return <RouteList {...props} />
|
||
case 'text-block': return <TextBlock {...props} />
|
||
case 'map-canvas': return <MapCanvas {...props} />
|
||
default:
|
||
return <div style={{ fontSize: '0.75rem', color: 'var(--otivm-ink-ghost)', fontStyle: 'italic' }}>Unknown section type: {type}</div>
|
||
}
|
||
}
|
||
|
||
// ── Status block ──────────────────────────────────────────────────────────
|
||
// props.items: [{ key, value, cls }]
|
||
// cls: 'good' | 'warn' | 'bad' | ''
|
||
|
||
function StatusBlock({ items = [] }) {
|
||
return (
|
||
<div>
|
||
{items.map((item, i) => (
|
||
<div key={i} className="otivm-status-row">
|
||
<span className="otivm-status-key">{item.key}</span>
|
||
<span className={`otivm-status-val${item.cls ? ' ' + item.cls : ''}`}>{item.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Parameter list ────────────────────────────────────────────────────────
|
||
// props.items: [{ token, name, true_val, perceived, conf }]
|
||
// Shows a gap indicator when true_val !== perceived.
|
||
|
||
function ParameterList({ items = [] }) {
|
||
return (
|
||
<div>
|
||
{items.map((item, i) => {
|
||
const gap = item.true_val !== item.perceived
|
||
return (
|
||
<div key={i} className="otivm-param-row">
|
||
<div>
|
||
<div className="otivm-param-token">{item.token}</div>
|
||
<div className="otivm-param-name">{item.name}</div>
|
||
</div>
|
||
<div style={{ textAlign: 'right' }}>
|
||
<span className={`otivm-band ${bandClass(item.perceived)}`}>
|
||
{item.perceived}
|
||
</span>
|
||
{gap && (
|
||
<div>
|
||
<span className="otivm-band otivm-band-gap" style={{ marginTop: '3px' }}>
|
||
true: {item.true_val}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<span className="otivm-param-conf">{item.conf}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Auctoritas ────────────────────────────────────────────────────────────
|
||
// props.faces: [{ label, value, discrepant }]
|
||
// props.gap_note: string shown when discrepancy exists
|
||
|
||
function Auctoritas({ faces = [], gap_note }) {
|
||
const hasGap = faces.some(f => f.discrepant)
|
||
return (
|
||
<div>
|
||
<div className="otivm-auct-three">
|
||
{faces.map((face, i) => (
|
||
<div key={i} className={`otivm-auct-face${face.discrepant ? ' discrepant' : ''}`}>
|
||
<div className="otivm-auct-face-label">{face.label}</div>
|
||
<div className="otivm-auct-face-value">{face.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{hasGap && gap_note && (
|
||
<div style={{ marginTop: '8px', fontSize: '0.72rem', fontStyle: 'italic', color: 'var(--otivm-gold)', lineHeight: 1.5 }}>
|
||
{gap_note}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Cost table ────────────────────────────────────────────────────────────
|
||
// props.note: optional note shown above table
|
||
// props.items: [{ label, amount, period, source, conf, debit }]
|
||
|
||
function CostTable({ note, items = [] }) {
|
||
return (
|
||
<div>
|
||
{note && (
|
||
<div style={{ fontSize: '0.7rem', fontStyle: 'italic', color: 'var(--otivm-ink-ghost)', marginBottom: '10px' }}>
|
||
{note}
|
||
</div>
|
||
)}
|
||
<table className="otivm-cost-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Item</th>
|
||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||
<th>Source · Conf.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((item, i) => (
|
||
<tr key={i}>
|
||
<td>
|
||
{item.label}
|
||
{item.period && <div className="source">{item.period}</div>}
|
||
</td>
|
||
<td className={`amount ${item.debit ? 'debit' : 'credit'}`}>
|
||
{item.debit ? '−' : '+'}{item.amount}
|
||
</td>
|
||
<td>
|
||
<span className="source">{item.source} · {item.conf}</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Drift log ─────────────────────────────────────────────────────────────
|
||
// props.items: [{ param, delta, trigger, note, positive }]
|
||
|
||
function DriftLog({ items = [] }) {
|
||
return (
|
||
<div>
|
||
{items.map((item, i) => (
|
||
<div key={i} className="otivm-drift-entry">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||
<span className="otivm-drift-param">{item.param}</span>
|
||
<span className={item.positive ? 'otivm-drift-pos' : 'otivm-drift-neg'}>{item.delta}</span>
|
||
</div>
|
||
<div className="otivm-drift-trigger">{item.trigger}</div>
|
||
{item.note && <div className="otivm-drift-note">{item.note}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Action bar ────────────────────────────────────────────────────────────
|
||
// props.buttons: [{ label, style, action, disabled }]
|
||
// props.onAction: fn(action) called when a button is clicked
|
||
|
||
function ActionBar({ buttons = [], onAction }) {
|
||
return (
|
||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||
{buttons.map((btn, i) => (
|
||
<button
|
||
key={i}
|
||
className={`btn ${btn.style || 'otivm-btn-dispatch'}`}
|
||
disabled={btn.disabled}
|
||
onClick={() => onAction && onAction(btn.action)}
|
||
>
|
||
{btn.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Route list ────────────────────────────────────────────────────────────
|
||
// props.routes: [{ id, name, goods, mode, days, cost, profit,
|
||
// vectura, portoria, other, locked, lock_reason }]
|
||
// props.selectedRoute: id of currently selected route
|
||
// props.onSelect: fn(id) called when a route is clicked
|
||
|
||
function RouteList({ routes = [], selectedRoute, onSelect }) {
|
||
return (
|
||
<div>
|
||
{routes.map(route => {
|
||
if (route.locked) {
|
||
return (
|
||
<div key={route.id} className="otivm-route-card locked">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||
<span className="otivm-route-name">{route.name}</span>
|
||
<span style={{ fontSize: '0.68rem', color: 'var(--otivm-ink-ghost)' }}>locked</span>
|
||
</div>
|
||
<div className="otivm-route-goods">{route.goods} · {route.mode}</div>
|
||
{route.lock_reason && (
|
||
<div style={{ fontSize: '0.7rem', fontStyle: 'italic', color: 'var(--otivm-ink-ghost)' }}>
|
||
Requires {route.lock_reason}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
const net = route.profit - route.cost
|
||
const isSelected = selectedRoute === route.id
|
||
return (
|
||
<div
|
||
key={route.id}
|
||
className={`otivm-route-card${isSelected ? ' selected' : ''}`}
|
||
onClick={() => onSelect && onSelect(route.id)}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||
<span className="otivm-route-name">{route.name}</span>
|
||
<span className="otivm-route-net">+{net} dn net</span>
|
||
</div>
|
||
<div className="otivm-route-goods">
|
||
{route.goods} · {route.mode} · {route.days} sim. days
|
||
</div>
|
||
<div className="otivm-route-costs">
|
||
<div className="otivm-cost-cell">
|
||
<div className="otivm-cost-cell-label">Vectura</div>
|
||
<div className="otivm-cost-cell-value">{route.vectura} dn</div>
|
||
</div>
|
||
<div className="otivm-cost-cell">
|
||
<div className="otivm-cost-cell-label">Portoria</div>
|
||
<div className="otivm-cost-cell-value">{route.portoria} dn</div>
|
||
</div>
|
||
<div className="otivm-cost-cell">
|
||
<div className="otivm-cost-cell-label">Other</div>
|
||
<div className="otivm-cost-cell-value">{route.other} dn</div>
|
||
</div>
|
||
<div className="otivm-cost-cell rev">
|
||
<div className="otivm-cost-cell-label">Revenue</div>
|
||
<div className="otivm-cost-cell-value">{route.profit} dn</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Text block ────────────────────────────────────────────────────────────
|
||
// props.entries: [{ date, text }]
|
||
// props.collapsible: boolean
|
||
|
||
function TextBlock({ entries = [], collapsible = false }) {
|
||
const [open, setOpen] = useState(!collapsible)
|
||
|
||
return (
|
||
<div>
|
||
{collapsible && (
|
||
<button
|
||
className="btn otivm-btn-dispatch"
|
||
style={{ width: '100%', marginBottom: '8px', fontSize: '0.65rem' }}
|
||
onClick={() => setOpen(o => !o)}
|
||
>
|
||
{open ? 'Hide Journal' : 'Show Journal'}
|
||
</button>
|
||
)}
|
||
{open && entries.map((entry, i) => (
|
||
<div key={i} className="otivm-journal-entry">
|
||
<div className="otivm-journal-date">{entry.date}</div>
|
||
<div className="otivm-journal-text">{entry.text}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Map canvas ────────────────────────────────────────────────────────────
|
||
// Placeholder — the real Map.jsx component is rendered by the MAP context screen.
|
||
// This type exists so Section.jsx can reference it; in production the MAP
|
||
// context screen passes the real Map.jsx output as a child instead.
|
||
|
||
function MapCanvas() {
|
||
return (
|
||
<div className="otivm-map-canvas" style={{ minHeight: '480px' }}>
|
||
<span>TESSERA H7 · roman_14bce</span>
|
||
<span style={{ fontFamily: 'Crimson Pro, serif', fontStyle: 'italic', fontSize: '0.78rem', letterSpacing: 0, color: '#1e3040', marginTop: '8px' }}>
|
||
Map renders here — src/screens/Map.jsx
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
function bandClass(val) {
|
||
if (!val) return 'otivm-band-low'
|
||
const v = val.toLowerCase()
|
||
if (v === 'high' || v === 'distinguished') return 'otivm-band-high'
|
||
if (v === 'medium' || v === 'neutral') return 'otivm-band-medium'
|
||
return 'otivm-band-low'
|
||
}
|