diff options
| -rw-r--r-- | frontend/app/prism-shell.css | 187 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosCard.tsx | 212 |
2 files changed, 399 insertions, 0 deletions
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index 9a37bdd..424ebb3 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1490,3 +1490,190 @@ font-variant-numeric: tabular-nums; text-align: right; } + +/* ── Key ratios tab ─────────────────────────────── */ + +.psm-ratio-card { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-ratio-heroes { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.psm-ratio-hero { + display: flex; + flex-direction: column; + gap: var(--sp-3); + min-width: 0; + padding: var(--sp-4); + background: var(--ink-2); + border: 1px solid var(--line-1); + border-radius: var(--r-2); +} + +.psm-ratio-hero-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); +} + +.psm-ratio-hero-label { + color: var(--fg-3); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-ratio-hero-sector { + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.psm-ratio-hero-value { + font-family: var(--font-mono); + font-size: var(--fs-32); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.psm-ratio-hero-spark { + width: 100%; + height: 52px; +} + +.psm-ratio-detail { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-ratio-group-label { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 96px 88px; + gap: var(--sp-3); + align-items: center; + padding-bottom: var(--sp-2); + border-bottom: 1px solid var(--line-1); + color: var(--fg-4); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-ratio-group-label span:nth-child(2), +.psm-ratio-group-label span:nth-child(3) { + text-align: right; +} + +.psm-ratio-group-label span:last-child { + text-align: center; +} + +.psm-ratio-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 96px 88px; + gap: var(--sp-3); + align-items: center; + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--ink-2); +} + +.psm-ratio-row-label { + color: var(--fg-2); + font-size: var(--fs-13); +} + +.psm-ratio-row-value, +.psm-ratio-row-sector { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-13); + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.psm-ratio-row-sector { + color: var(--fg-4); +} + +.psm-ratio-mini-spark { + width: 88px; + height: 24px; +} + +.psm-ratio-mini-spark, +.psm-ratio-hero-spark { + display: block; +} + +.psm-ratio-spark-empty { + display: inline-flex; + align-items: center; + justify-content: center; + width: 88px; + height: 24px; + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; +} + +.psm-ratio-hero .psm-ratio-spark-empty { + width: 100%; + height: 52px; + justify-content: flex-start; +} + +@media (max-width: 980px) { + .psm-ratio-group-label, + .psm-ratio-row { + grid-template-columns: minmax(0, 1fr) 88px 88px 72px; + } + + .psm-ratio-mini-spark, + .psm-ratio-spark-empty { + width: 72px; + } +} + +@media (max-width: 680px) { + .psm-ratio-heroes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .psm-ratio-group-label { + grid-template-columns: minmax(0, 1fr) 84px 84px 64px; + gap: var(--sp-2); + } + + .psm-ratio-row { + grid-template-columns: minmax(0, 1fr) 84px 84px 64px; + gap: var(--sp-2); + } + + .psm-ratio-hero-head { + flex-direction: column; + align-items: flex-start; + } + + .psm-ratio-hero-sector { + text-align: left; + } + + .psm-ratio-mini-spark, + .psm-ratio-spark-empty { + width: 64px; + } +} diff --git a/frontend/components/prism/RatiosCard.tsx b/frontend/components/prism/RatiosCard.tsx new file mode 100644 index 0000000..1a00829 --- /dev/null +++ b/frontend/components/prism/RatiosCard.tsx @@ -0,0 +1,212 @@ +"use client"; + +import type { RatioPoint, RatiosResponse } from "@/types/api"; +import { fmtNumber, fmtPct } from "@/lib/format"; + +const BRASS = "#C2AA7A"; +const GAIN = "#4F8C5E"; + +type Props = { + data: RatiosResponse; +}; + +type ValueKind = "multiple" | "percent" | "coverage"; + +function buildLine(values: (number | null)[], width: number, height: number): string { + const numeric = values.filter((value): value is number => value != null && Number.isFinite(value)); + if (numeric.length === 0) return ""; + if (numeric.length === 1) { + const y = height / 2; + return `0,${y} ${width},${y}`; + } + + const min = Math.min(...numeric); + const max = Math.max(...numeric); + const range = max - min || 1; + + return numeric + .map((value, index) => { + const x = (index / (numeric.length - 1)) * width; + const y = max === min ? height / 2 : height - ((value - min) / range) * height; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); +} + +function fmtMultiple(value?: number | null): string { + if (value == null || Number.isNaN(value)) return "—"; + return `${fmtNumber(value)}x`; +} + +function fmtCoverage(value?: number | null): string { + if (value == null || Number.isNaN(value)) return "—"; + return `${fmtNumber(value)}x`; +} + +function formatValue(value: number | null, kind: ValueKind): string { + if (kind === "percent") return fmtPct(value); + if (kind === "coverage") return fmtCoverage(value); + return fmtMultiple(value); +} + +function MiniSpark({ values, color }: { values: (number | null)[]; color: string }) { + const points = buildLine(values, 88, 24); + if (!points) { + return <span className="psm-ratio-spark-empty">—</span>; + } + + return ( + <svg className="psm-ratio-mini-spark" viewBox="0 0 88 24" aria-hidden="true"> + <polyline + points={points} + fill="none" + stroke={color} + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function HeroSpark({ values, color }: { values: (number | null)[]; color: string }) { + const points = buildLine(values, 196, 52); + if (!points) { + return <span className="psm-ratio-spark-empty">No trend</span>; + } + + return ( + <svg className="psm-ratio-hero-spark" viewBox="0 0 196 52" aria-hidden="true"> + <polyline + points={points} + fill="none" + stroke={color} + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function HeroCard({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( + <article className="psm-ratio-hero"> + <div className="psm-ratio-hero-head"> + <span className="psm-ratio-hero-label">{label}</span> + <span className="psm-ratio-hero-sector"> + Sector {formatValue(point.vs_sector, kind)} + </span> + </div> + <div className="psm-ratio-hero-value" style={{ color }}> + {formatValue(point.value, kind)} + </div> + <HeroSpark values={point.spark} color={color} /> + </article> + ); +} + +function DetailRow({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( + <div className="psm-ratio-row"> + <span className="psm-ratio-row-label">{label}</span> + <span className="psm-ratio-row-value">{formatValue(point.value, kind)}</span> + <span className="psm-ratio-row-sector">{formatValue(point.vs_sector, kind)}</span> + <MiniSpark values={point.spark} color={color} /> + </div> + ); +} + +function GroupHeader({ label }: { label: string }) { + return ( + <div className="psm-ratio-group-label"> + <span>{label}</span> + <span>Current</span> + <span>Sector</span> + <span>Trend</span> + </div> + ); +} + +export function RatiosCard({ data }: Props) { + const showDividends = data.dividend_yield.value != null || data.dividend_payout.value != null; + + return ( + <section className="psm-card psm-ratio-card"> + <div className="psm-card-head"> + <div> + <div className="psm-eyebrow">Key Ratios</div> + <h2 className="psm-card-title">Key Ratios</h2> + </div> + </div> + + <div className="psm-ratio-heroes"> + <HeroCard label="P / E TTM" point={data.pe_ttm} kind="multiple" color={BRASS} /> + <HeroCard label="EV / EBITDA" point={data.ev_ebitda} kind="multiple" color={BRASS} /> + <HeroCard label="Gross Margin" point={data.gross_margin} kind="percent" color={GAIN} /> + <HeroCard label="Net Margin" point={data.net_margin} kind="percent" color={GAIN} /> + </div> + + <div className="psm-ratio-detail"> + <section> + <GroupHeader label="Valuation" /> + <DetailRow label="P / Book" point={data.price_to_book} kind="multiple" color={BRASS} /> + <DetailRow label="P / Sales" point={data.price_to_sales} kind="multiple" color={BRASS} /> + <DetailRow label="EV / Sales" point={data.ev_to_sales} kind="multiple" color={BRASS} /> + <DetailRow label="P / FCF" point={data.p_fcf} kind="multiple" color={BRASS} /> + <DetailRow label="Forward P / E" point={data.forward_pe} kind="multiple" color={BRASS} /> + </section> + + <section> + <GroupHeader label="Profitability" /> + <DetailRow label="Operating Margin" point={data.operating_margin} kind="percent" color={GAIN} /> + <DetailRow label="EBITDA Margin" point={data.ebitda_margin} kind="percent" color={GAIN} /> + <DetailRow label="FCF Margin" point={data.fcf_margin} kind="percent" color={GAIN} /> + </section> + + <section> + <GroupHeader label="Returns" /> + <DetailRow label="ROE" point={data.roe} kind="percent" color={GAIN} /> + <DetailRow label="ROA" point={data.roa} kind="percent" color={GAIN} /> + <DetailRow label="ROIC" point={data.roic} kind="percent" color={GAIN} /> + </section> + + <section> + <GroupHeader label="Leverage / Liquidity" /> + <DetailRow label="Debt / Equity" point={data.debt_to_equity} kind="multiple" color={BRASS} /> + <DetailRow label="Current Ratio" point={data.current_ratio} kind="multiple" color={BRASS} /> + <DetailRow label="Quick Ratio" point={data.quick_ratio} kind="multiple" color={BRASS} /> + <DetailRow label="Interest Coverage" point={data.interest_coverage} kind="coverage" color={BRASS} /> + </section> + + {showDividends && ( + <section> + <GroupHeader label="Dividends" /> + <DetailRow label="Dividend Yield" point={data.dividend_yield} kind="percent" color={GAIN} /> + <DetailRow label="Payout Ratio" point={data.dividend_payout} kind="percent" color={GAIN} /> + </section> + )} + </div> + </section> + ); +} |
