Files
otivm/src/components/Section.jsx

335 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'
}