diff --git a/apps/web/app/charts/page.tsx b/apps/web/app/charts/page.tsx
new file mode 100644
index 0000000..b1b8870
--- /dev/null
+++ b/apps/web/app/charts/page.tsx
@@ -0,0 +1,5 @@
+import { ChartsRoute } from "../terminal";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 2da9583..0769f2a 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1,645 +1,803 @@
:root {
- color-scheme: light;
- font-family: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
- background: #efece6;
- color: #1d1d1b;
- --panel: #fff6e8;
- --panel-border: #d9cdb8;
- --accent: #2f6d4f;
- --accent-soft: rgba(47, 109, 79, 0.18);
- --warning: #c46f2a;
- --replay: #1f4a7b;
- --grid: rgba(82, 64, 36, 0.12);
+ color-scheme: dark;
+ --bg: #06080b;
+ --bg-elevated: #0b1016;
+ --bg-pane: #111820;
+ --bg-pane-2: #0d141b;
+ --bg-soft: rgba(255, 255, 255, 0.03);
+ --border: rgba(255, 255, 255, 0.08);
+ --border-strong: rgba(255, 177, 48, 0.35);
+ --text: #e6edf4;
+ --text-dim: #90a0b2;
+ --text-faint: #6e7b8c;
+ --accent: #f5a623;
+ --accent-soft: rgba(245, 166, 35, 0.12);
+ --green: #25c17a;
+ --green-soft: rgba(37, 193, 122, 0.12);
+ --red: #ff6b5f;
+ --red-soft: rgba(255, 107, 95, 0.14);
+ --blue: #4da3ff;
+ --blue-soft: rgba(77, 163, 255, 0.14);
+ --rail-width: 236px;
+ --topbar-height: 76px;
}
* {
box-sizing: border-box;
}
+html,
body {
margin: 0;
- min-height: 100vh;
+ min-height: 100%;
}
-.dashboard {
+body {
min-height: 100vh;
- padding: 48px 8vw 72px;
- display: grid;
- gap: 32px;
+ font-family: var(--font-sans), sans-serif;
+ color: var(--text);
background:
- radial-gradient(circle at top left, #fff7df 0%, #efece6 56%),
- repeating-linear-gradient(
- 90deg,
- transparent,
- transparent 44px,
- var(--grid) 45px,
- var(--grid) 46px
- );
+ radial-gradient(circle at top left, rgba(245, 166, 35, 0.12), transparent 26%),
+ linear-gradient(180deg, #081017 0%, #05070a 100%);
}
-.header {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 24px;
- flex-wrap: wrap;
+a {
+ color: inherit;
+ text-decoration: none;
}
-.eyebrow {
- text-transform: uppercase;
- letter-spacing: 0.4em;
- font-size: 0.7rem;
- margin: 0 0 12px;
- color: #6f5b39;
+button,
+input {
+ font: inherit;
}
-h1 {
- margin: 0 0 12px;
- font-size: clamp(2.2rem, 4vw, 3.4rem);
- letter-spacing: 0.15em;
- text-transform: uppercase;
-}
-
-.subtitle {
- margin: 0;
- max-width: 460px;
- line-height: 1.6;
- color: #4e3e25;
-}
-
-.summary {
+.terminal-shell {
+ min-height: 100vh;
display: grid;
- gap: 12px;
- padding: 16px 20px;
- border-radius: 16px;
- border: 1px solid var(--panel-border);
- background: #fffdf7;
- min-width: 220px;
+ grid-template-columns: var(--rail-width) minmax(0, 1fr);
+ background:
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 18%),
+ var(--bg);
+ background-size: 32px 32px, 32px 32px, 100% 100%, auto;
}
-.filter-bar {
+.terminal-rail {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ padding: 24px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ background: linear-gradient(180deg, rgba(11, 16, 22, 0.96), rgba(6, 8, 11, 0.98));
+ border-right: 1px solid var(--border);
+}
+
+.terminal-brand {
+ display: grid;
+ gap: 4px;
+}
+
+.terminal-brand-kicker {
+ font-family: var(--font-display), sans-serif;
+ font-size: 0.78rem;
+ letter-spacing: 0.24em;
+ color: var(--accent);
+}
+
+.terminal-brand-name {
+ font-family: var(--font-display), sans-serif;
+ font-size: 1.8rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.terminal-nav {
+ display: grid;
+ gap: 6px;
+}
+
+.terminal-nav-link {
+ padding: 12px 14px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.78rem;
+ transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
+}
+
+.terminal-nav-link:hover {
+ border-color: var(--border);
+ color: var(--text);
+ background: var(--bg-soft);
+}
+
+.terminal-nav-link-active {
+ border-color: var(--border-strong);
+ color: var(--text);
+ background: linear-gradient(90deg, rgba(245, 166, 35, 0.12), rgba(245, 166, 35, 0.04));
+}
+
+.shell-metrics {
+ margin-top: auto;
+ display: grid;
+ gap: 10px;
+}
+
+.shell-metric {
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.shell-metric-label,
+.overview-label,
+.focus-label,
+.terminal-filter-label,
+.summary-title,
+.filter-label {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--text-faint);
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ font-size: 0.68rem;
+}
+
+.shell-metric-value,
+.overview-cell strong,
+.focus-value {
+ font-family: var(--font-mono), monospace;
+ font-size: 0.92rem;
+}
+
+.terminal-frame {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: var(--topbar-height) minmax(0, 1fr);
+}
+
+.terminal-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
- gap: 20px;
- padding: 16px 20px;
- border-radius: 18px;
- border: 1px solid var(--panel-border);
- background: rgba(255, 253, 247, 0.9);
+ gap: 18px;
+ padding: 14px 24px;
+ background: rgba(7, 10, 14, 0.92);
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--border);
}
-.filter-label {
- margin: 0 0 6px;
+.feed-status-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.feed-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
+ color: var(--text-dim);
+ font-family: var(--font-mono), monospace;
+ font-size: 0.75rem;
+}
+
+.feed-status-dot,
+.status-dot,
+.chart-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--text-faint);
+}
+
+.feed-status-connected .feed-status-dot,
+.status-connected .status-dot,
+.chart-status-connected .chart-dot {
+ background: var(--green);
+ box-shadow: 0 0 0 4px rgba(37, 193, 122, 0.14);
+}
+
+.feed-status-connecting .feed-status-dot,
+.chart-status-connecting .chart-dot {
+ background: var(--accent);
+ box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.12);
+ animation: pulse 1.3s ease-in-out infinite;
+}
+
+.feed-status-disconnected .feed-status-dot,
+.status-disconnected .status-dot,
+.chart-status-disconnected .chart-dot {
+ background: var(--red);
+ box-shadow: 0 0 0 4px rgba(255, 107, 95, 0.12);
+}
+
+.terminal-topbar-controls {
+ display: flex;
+ align-items: flex-end;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+
+.terminal-filter {
+ display: grid;
+ gap: 2px;
+ min-width: clamp(220px, 22vw, 320px);
+ flex: 0 1 320px;
+}
+
+.terminal-filter-label {
+ margin-bottom: 0;
+ line-height: 1;
+}
+
+.terminal-filter-field {
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-height: 34px;
+}
+
+.terminal-filter-field::before,
+.terminal-filter-field::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease,
+ box-shadow 160ms ease,
+ background 160ms ease;
+}
+
+.terminal-filter-field::before {
+ height: 1px;
+ background: linear-gradient(90deg, rgba(245, 166, 35, 0.88), rgba(245, 166, 35, 0.14));
+ opacity: 0.72;
+}
+
+.terminal-filter-field::after {
+ height: 2px;
+ background: linear-gradient(90deg, rgba(255, 216, 154, 0.98), rgba(245, 166, 35, 0.92));
+ transform: scaleX(0.18);
+ transform-origin: left center;
+ opacity: 0;
+}
+
+.terminal-input {
+ min-width: 0;
+ width: 100%;
+ padding: 0 0 6px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text);
+ font-family: var(--font-mono), monospace;
+ font-size: 0.92rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.terminal-input::placeholder {
+ color: rgba(193, 203, 224, 0.58);
+ font-size: 0.86rem;
+}
+
+.terminal-filter:focus-within .terminal-filter-label {
+ color: #ffd89a;
+}
+
+.terminal-filter:focus-within .terminal-filter-field::before {
+ background: linear-gradient(90deg, rgba(255, 216, 154, 0.9), rgba(245, 166, 35, 0.26));
+ opacity: 0.94;
+}
+
+.terminal-filter:focus-within .terminal-filter-field::after {
+ transform: scaleX(1);
+ opacity: 1;
+ box-shadow: 0 0 18px rgba(245, 166, 35, 0.34);
+}
+
+.terminal-filter:focus-within .terminal-input {
+ color: #fff1cf;
+ text-shadow: 0 0 14px rgba(245, 166, 35, 0.16);
+}
+
+.terminal-input:focus-visible,
+.filter-input:focus-visible {
+ outline: none;
+}
+
+.terminal-button,
+.mode-button,
+.filter-clear,
+.jump-button,
+.pause-button,
+.interval-button,
+.overlay-toggle,
+.drawer-close {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text);
+ cursor: pointer;
text-transform: uppercase;
- letter-spacing: 0.3em;
- font-size: 0.7rem;
- color: #6f5b39;
+ letter-spacing: 0.12em;
+ font-size: 0.72rem;
}
-.filter-help {
+.terminal-button:disabled,
+.filter-clear:disabled,
+.jump-button:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+
+.terminal-button-primary,
+.interval-button.active,
+.overlay-toggle.overlay-toggle-on,
+.mode-button {
+ border-color: var(--border-strong);
+ background: linear-gradient(180deg, rgba(245, 166, 35, 0.18), rgba(245, 166, 35, 0.08));
+ color: #ffd89a;
+}
+
+.pause-button {
+ padding: 7px 10px;
+ font-size: 0.66rem;
+}
+
+.terminal-content {
+ min-width: 0;
+ padding: 34px 24px 28px;
+}
+
+.page-shell {
+ display: grid;
+ gap: 18px;
+}
+
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.page-title,
+h1,
+h2,
+h3 {
margin: 0;
- color: #4e3e25;
- font-size: 0.9rem;
+ font-family: var(--font-display), sans-serif;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
}
-.filter-controls {
+.page-title {
+ font-size: clamp(2rem, 3vw, 2.8rem);
+}
+
+.page-actions {
display: flex;
align-items: center;
gap: 10px;
}
-.filter-input {
- border: 1px solid rgba(111, 91, 57, 0.35);
- border-radius: 999px;
- padding: 8px 14px;
- min-width: 220px;
- background: #fffdf7;
- font-family: inherit;
- font-size: 0.9rem;
- color: #1d1d1b;
-}
-
-.filter-input:focus-visible {
- outline: 2px solid rgba(47, 109, 79, 0.3);
- outline-offset: 2px;
-}
-
-.filter-clear {
- border: 1px solid rgba(111, 91, 57, 0.35);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(111, 91, 57, 0.08);
- color: #6f5b39;
- font-size: 0.7rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.filter-clear:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-.summary-title {
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.28em;
- color: #6f5b39;
-}
-
-.summary-value {
- font-size: 1rem;
-}
-
-.mode-button {
- border: 1px solid rgba(31, 74, 123, 0.35);
- border-radius: 999px;
- padding: 8px 14px;
- background: rgba(31, 74, 123, 0.12);
- color: #1f4a7b;
- font-size: 0.75rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.mode-button:focus-visible {
- outline: 2px solid rgba(31, 74, 123, 0.4);
- outline-offset: 2px;
-}
-
-.cards {
+.overview-strip,
+.replay-matrix {
display: grid;
- gap: 28px;
+ gap: 12px;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
-.card-options {
- grid-column: span 4;
+.overview-cell {
+ min-width: 0;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02));
}
-.card-chart {
- grid-column: span 6;
-}
-
-.card-equities {
- grid-column: span 2;
-}
-
-.card-flow,
-.card-alerts,
-.card-classifiers,
-.card-dark {
- grid-column: span 2;
-}
-
-.status {
+.page-grid {
display: grid;
- gap: 8px;
- padding: 16px 20px;
- border-radius: 16px;
- border: 1px solid var(--panel-border);
- background: #fffdf7;
- min-width: 220px;
+ gap: 16px;
+ align-items: stretch;
}
-.status-compact {
- padding: 12px 16px;
- min-width: 180px;
+.page-grid-overview {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
}
-.status-paused {
- background: #fff3e4;
+.page-grid-tape {
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 1fr);
}
-.status-dot {
- width: 12px;
- height: 12px;
- border-radius: 999px;
- background: var(--warning);
- box-shadow: 0 0 0 4px rgba(196, 111, 42, 0.2);
+.page-grid-signals {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: minmax(0, 1fr);
+ height: calc(100vh - var(--topbar-height) - 172px);
+ min-height: 620px;
}
-.status-connected .status-dot {
- background: var(--accent);
- box-shadow: 0 0 0 4px var(--accent-soft);
+.page-grid-charts {
+ grid-template-columns: minmax(0, 1.7fr) minmax(320px, 1fr);
}
-.status-replay .status-dot {
- background: var(--replay);
- box-shadow: 0 0 0 4px rgba(31, 74, 123, 0.18);
+.page-grid-replay {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
}
-.status-connecting .status-dot {
- animation: pulse 1.2s ease-in-out infinite;
+.page-grid-overview > :nth-child(1),
+.page-grid-tape > :nth-child(1),
+.page-grid-replay > :nth-child(1) {
+ grid-column: 1 / -1;
}
-.status span {
- font-size: 0.95rem;
-}
-
-.timestamp {
- font-size: 0.8rem;
- color: #6f5b39;
-}
-
-.pause-button {
- border: 1px solid rgba(47, 109, 79, 0.3);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(47, 109, 79, 0.12);
- color: #2f6d4f;
- font-size: 0.75rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.status-paused .pause-button {
- border-color: rgba(196, 111, 42, 0.4);
- background: rgba(196, 111, 42, 0.16);
- color: #8c4a16;
-}
-
-.pause-button:focus-visible {
- outline: 2px solid rgba(47, 109, 79, 0.4);
- outline-offset: 2px;
-}
-
-.card-controls {
+.terminal-pane {
+ min-width: 0;
+ height: 100%;
display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 12px;
- width: 100%;
- margin-bottom: 20px;
- flex: 0 0 auto;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%),
+ var(--bg-pane);
+ overflow: hidden;
}
-.chart-controls {
+.terminal-pane-head {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 16px;
- margin-bottom: 18px;
- flex-wrap: wrap;
+ gap: 12px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
}
-.chart-intervals {
+.terminal-pane-title-row {
display: flex;
- flex-wrap: wrap;
+ align-items: center;
+ gap: 14px;
+ min-width: 0;
+}
+
+.terminal-pane-title {
+ font-size: 1rem;
+}
+
+.terminal-pane-status {
+ min-width: 0;
+}
+
+.terminal-pane-actions,
+.card-controls,
+.chart-controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
gap: 8px;
+ flex-wrap: wrap;
}
-.interval-button {
- border: 1px solid rgba(111, 91, 57, 0.35);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(111, 91, 57, 0.08);
- color: #6f5b39;
- font-size: 0.75rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.interval-button.active {
- border-color: rgba(47, 109, 79, 0.6);
- background: rgba(47, 109, 79, 0.1);
- color: #2f6d4f;
- box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12);
-}
-
-.interval-button:focus-visible {
- outline: 2px solid rgba(47, 109, 79, 0.4);
- outline-offset: 2px;
-}
-
-.chart-hint {
- font-size: 0.8rem;
- color: #6f5b39;
+.terminal-pane-body,
+.card-body {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ gap: 14px;
+ padding: 16px 18px 18px;
}
.chart-panel {
display: grid;
- gap: 16px;
+ gap: 12px;
}
.chart-meta {
display: flex;
align-items: center;
- gap: 16px;
+ gap: 12px;
flex-wrap: wrap;
font-size: 0.8rem;
- color: #5b4c34;
-}
-
-.overlay-toggle {
- border: 1px solid rgba(31, 74, 123, 0.35);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(31, 74, 123, 0.12);
- color: #1f4a7b;
- font-size: 0.7rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.overlay-toggle.overlay-toggle-on {
- border-color: rgba(31, 74, 123, 0.6);
- background: rgba(31, 74, 123, 0.2);
-}
-
-.overlay-toggle:focus-visible {
- outline: 2px solid rgba(31, 74, 123, 0.4);
- outline-offset: 2px;
-}
-
-.overlay-legend {
- color: #6f5b39;
- font-size: 0.75rem;
-}
-
-@media (max-width: 700px) {
- .overlay-legend {
- flex: 1 1 100%;
- }
+ color: var(--text-dim);
}
.chart-status {
display: inline-flex;
align-items: center;
gap: 8px;
- font-weight: 600;
}
-.chart-dot {
- width: 8px;
- height: 8px;
- border-radius: 999px;
- background: rgba(111, 91, 57, 0.4);
-}
-
-.chart-status-connected .chart-dot {
- background: rgba(47, 109, 79, 0.8);
-}
-
-.chart-status-connecting .chart-dot {
- background: rgba(31, 74, 123, 0.8);
- animation: pulse 1.4s ease-in-out infinite;
-}
-
-.chart-status-disconnected .chart-dot {
- background: rgba(196, 111, 42, 0.8);
-}
-
-.chart-meta-time {
- color: #6f5b39;
+.chart-meta-time,
+.chart-hint,
+.overlay-legend,
+.timestamp,
+.drawer-subtitle,
+.drawer-note,
+.drawer-empty,
+.note,
+.time {
+ color: var(--text-dim);
}
.chart-surface {
width: 100%;
- height: 360px;
- border-radius: 18px;
- border: 1px solid rgba(217, 205, 184, 0.6);
- background: #fffdf7;
+ height: 460px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: #0b1218;
overflow: hidden;
}
.chart-empty {
- margin-top: -4px;
+ margin-top: -2px;
+}
+
+.chart-intervals {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.status span,
+.timestamp,
+.missed-count,
+.chart-hint,
+.meta,
+.note,
+.drawer-note,
+.drawer-row-meta {
+ font-family: var(--font-mono), monospace;
+}
+
+.status-replay .status-dot {
+ background: var(--blue);
+ box-shadow: 0 0 0 4px rgba(77, 163, 255, 0.14);
+}
+
+.status-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 32px;
+ min-width: 0;
+ font-family: var(--font-mono), monospace;
+}
+
+.status-inline-label {
+ color: var(--text);
+ font-size: 0.82rem;
+}
+
+.status-inline-meta {
+ color: var(--text-dim);
+ font-size: 0.72rem;
+}
+
+.status-inline-counter {
+ min-width: 82px;
+ color: var(--accent);
+ font-size: 0.72rem;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+
+.status-inline-counter-visible {
+ opacity: 1;
}
.tape-controls {
display: flex;
- flex-direction: column;
- align-items: flex-end;
+ align-items: center;
gap: 6px;
- min-width: 120px;
-}
-
-.jump-button {
- border: 1px solid rgba(111, 91, 57, 0.35);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(111, 91, 57, 0.08);
- color: #6f5b39;
- font-size: 0.75rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
-}
-
-.jump-button:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-.jump-button:not(:disabled) {
- border-color: rgba(47, 109, 79, 0.6);
- background: rgba(47, 109, 79, 0.1);
- color: #2f6d4f;
- box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.15);
-}
-
-.jump-button:focus-visible {
- outline: 2px solid rgba(111, 91, 57, 0.3);
- outline-offset: 2px;
+ flex-wrap: wrap;
}
.missed-count {
- padding: 4px 10px;
- border-radius: 999px;
- border: 1px solid rgba(31, 74, 123, 0.25);
- background: rgba(31, 74, 123, 0.12);
- color: #1f4a7b;
- font-size: 0.7rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- max-height: 0;
- opacity: 0;
- transform: translateY(-6px);
- overflow: hidden;
- transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
-}
-
-.tape-controls-active .jump-button {
- transform: translateY(-6px);
- transition: transform 0.2s ease;
-}
-
-.tape-controls-active .missed-count {
- max-height: 24px;
- opacity: 1;
- transform: translateY(0);
-}
-
-.card {
- border: 1px solid var(--panel-border);
- border-radius: 24px;
- background: var(--panel);
- box-shadow: 0 30px 60px rgba(66, 45, 18, 0.14);
- padding: 28px;
-}
-
-.card-header {
- display: flex;
- flex-direction: column;
- gap: 6px;
- align-items: flex-start;
- margin-bottom: 24px;
-}
-
-.card-header h2 {
- margin: 0 0 6px;
- font-size: 1.4rem;
-}
-
-.card-subtitle {
- margin: 0;
- color: #5b4c34;
+ min-width: 62px;
+ font-size: 0.72rem;
+ color: var(--accent);
+ text-align: right;
}
.list {
- display: grid;
- gap: 14px;
- height: 480px;
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ gap: 10px;
overflow: auto;
- padding-right: 6px;
- overflow-anchor: none;
- scrollbar-gutter: stable;
+ padding: 12px;
+ padding-right: 8px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.09));
+ align-self: stretch;
}
+.terminal-list-compact {
+ flex: 0 0 auto;
+ max-height: 260px;
+}
+
+.page-grid-overview > :not(:first-child),
+.page-grid-replay > :not(:first-child) {
+ height: clamp(430px, 58vh, 760px);
+}
+
+.page-grid-signals > .terminal-pane {
+ height: 100%;
+ min-height: 0;
+}
+
+.page-grid-tape > :first-child {
+ height: clamp(460px, 64vh, 880px);
+}
+
+.page-grid-tape > :not(:first-child) {
+ height: clamp(400px, 50vh, 680px);
+}
+
+.page-grid-charts > :first-child {
+ height: auto;
+}
+
+.page-grid-charts > :last-child {
+ height: clamp(430px, 58vh, 760px);
+}
.row {
display: flex;
justify-content: space-between;
- gap: 16px;
- padding: 16px 18px;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.72);
- border: 1px solid rgba(217, 205, 184, 0.6);
+ gap: 14px;
+ padding: 14px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.018));
}
.row-button {
width: 100%;
text-align: left;
- cursor: pointer;
- font: inherit;
color: inherit;
}
.row-button:hover {
- border-color: rgba(47, 109, 79, 0.4);
- box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12);
+ border-color: rgba(245, 166, 35, 0.25);
+ background: linear-gradient(180deg, rgba(245, 166, 35, 0.07), rgba(255, 255, 255, 0.018));
}
-.row-button:focus-visible {
- outline: 2px solid rgba(47, 109, 79, 0.4);
- outline-offset: 2px;
-}
-
-.contract {
- font-weight: 600;
+.contract,
+.drawer-row-title {
margin-bottom: 6px;
+ font-size: 0.92rem;
+ font-weight: 600;
}
-.meta {
+.meta,
+.drawer-row-meta,
+.flow-meta {
display: flex;
flex-wrap: wrap;
- gap: 12px;
- font-size: 0.85rem;
- color: #5b4c34;
+ gap: 10px;
+ font-size: 0.76rem;
}
-.pill {
- padding: 2px 8px;
+.pill,
+.drawer-chip,
+.flag {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 8px;
border-radius: 999px;
- font-size: 0.7rem;
+ border: 1px solid var(--border);
+ font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
- border: 1px solid rgba(111, 91, 57, 0.35);
- color: #6f5b39;
- background: rgba(111, 91, 57, 0.12);
}
.structure-tag {
- border-color: rgba(39, 84, 138, 0.45);
- color: #27548a;
- background: rgba(39, 84, 138, 0.12);
+ border-color: rgba(77, 163, 255, 0.36);
+ color: #9bcbff;
+ background: var(--blue-soft);
}
-.aggressor-tag {
- border-color: rgba(93, 70, 144, 0.45);
- color: #5d4690;
- background: rgba(93, 70, 144, 0.12);
+.aggressor-tag,
+.direction-bullish,
+.severity-low,
+.flag {
+ border-color: rgba(37, 193, 122, 0.34);
+ color: #98f0c0;
+ background: var(--green-soft);
+}
+
+.direction-bearish,
+.severity-high,
+.nbbo-missing {
+ border-color: rgba(255, 107, 95, 0.34);
+ color: #ffc3bd;
+ background: var(--red-soft);
+}
+
+.direction-neutral,
+.severity-medium,
+.flag-muted,
+.nbbo-stale {
+ border-color: rgba(77, 163, 255, 0.26);
+ color: #bddcff;
+ background: var(--blue-soft);
}
.nbbo-meta {
- font-size: 0.72rem;
- color: #6f5b39;
+ margin-top: 8px;
}
.nbbo-side {
position: relative;
display: inline-flex;
align-items: center;
- margin-left: 4px;
}
.nbbo-tag {
padding: 2px 6px;
border-radius: 999px;
- border: 1px solid rgba(111, 91, 57, 0.35);
- font-size: 0.7rem;
- letter-spacing: 0.08em;
+ border: 1px solid var(--border);
+ font-size: 0.68rem;
text-transform: uppercase;
}
-.nbbo-tag-a {
- border-color: rgba(47, 109, 79, 0.5);
- color: #2f6d4f;
- background: rgba(47, 109, 79, 0.16);
-}
-
+.nbbo-tag-a,
.nbbo-tag-aa {
- border-color: rgba(26, 87, 60, 0.6);
- color: #1a573c;
- background: rgba(26, 87, 60, 0.2);
-}
-
-.nbbo-tag-b {
- border-color: rgba(140, 74, 22, 0.5);
- color: #8c4a16;
- background: rgba(196, 111, 42, 0.18);
+ border-color: rgba(37, 193, 122, 0.34);
+ color: #98f0c0;
+ background: var(--green-soft);
}
+.nbbo-tag-b,
.nbbo-tag-bb {
- border-color: rgba(110, 44, 12, 0.6);
- color: #6e2c0c;
- background: rgba(110, 44, 12, 0.2);
+ border-color: rgba(255, 107, 95, 0.34);
+ color: #ffc3bd;
+ background: var(--red-soft);
}
.nbbo-tooltip {
position: absolute;
right: 0;
- bottom: 100%;
- transform: translateY(-6px);
+ bottom: calc(100% + 10px);
display: grid;
gap: 4px;
- padding: 8px 10px;
+ padding: 10px;
border-radius: 10px;
- border: 1px solid rgba(217, 205, 184, 0.8);
- background: #fffdf7;
- box-shadow: 0 12px 26px rgba(66, 45, 18, 0.18);
+ border: 1px solid var(--border);
+ background: rgba(7, 10, 14, 0.96);
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
+ transform: translateY(8px);
transition: opacity 0.15s ease, transform 0.15s ease;
- z-index: 2;
- white-space: nowrap;
+ z-index: 5;
}
.nbbo-tooltip-row {
@@ -647,82 +805,106 @@ h1 {
align-items: center;
gap: 6px;
font-size: 0.68rem;
- color: #6f5b39;
}
.nbbo-side:hover .nbbo-tooltip,
.nbbo-side:focus-within .nbbo-tooltip {
opacity: 1;
- transform: translateY(-10px);
+ transform: translateY(0);
}
-.nbbo-missing {
- border-color: rgba(136, 58, 17, 0.4);
- color: #8c3a11;
- background: rgba(196, 111, 42, 0.16);
+.alert-strips {
+ display: grid;
+ gap: 12px;
}
-.nbbo-stale {
- border-color: rgba(31, 74, 123, 0.4);
- color: #1f4a7b;
- background: rgba(31, 74, 123, 0.12);
+.alert-strip-section {
+ display: grid;
+ gap: 6px;
}
-.severity-high {
- border-color: rgba(136, 58, 17, 0.6);
- color: #8c3a11;
- background: rgba(196, 111, 42, 0.2);
+.alert-strip-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--text-faint);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.68rem;
}
-.severity-medium {
- border-color: rgba(31, 74, 123, 0.35);
- color: #1f4a7b;
- background: rgba(31, 74, 123, 0.12);
+.alert-strip-bar {
+ display: flex;
+ height: 24px;
+ overflow: hidden;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
}
-.severity-low {
- border-color: rgba(47, 109, 79, 0.35);
- color: #2f6d4f;
- background: rgba(47, 109, 79, 0.12);
+.strip-segment {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.64rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: #03130a;
}
-.direction-bullish {
- border-color: rgba(47, 109, 79, 0.35);
- color: #2f6d4f;
- background: rgba(47, 109, 79, 0.12);
+.alert-strip-bar .severity-high,
+.alert-strip-bar .direction-bearish {
+ background: rgba(255, 107, 95, 0.9);
+ color: #230705;
}
-.direction-bearish {
- border-color: rgba(136, 58, 17, 0.6);
- color: #8c3a11;
- background: rgba(196, 111, 42, 0.2);
+.alert-strip-bar .severity-medium,
+.alert-strip-bar .direction-neutral {
+ background: rgba(77, 163, 255, 0.9);
+ color: #04111f;
}
-.direction-neutral {
- border-color: rgba(111, 91, 57, 0.35);
- color: #6f5b39;
- background: rgba(111, 91, 57, 0.12);
+.alert-strip-bar .severity-low,
+.alert-strip-bar .direction-bullish {
+ background: rgba(37, 193, 122, 0.92);
+ color: #03130a;
}
-.note {
- margin-top: 8px;
- font-size: 0.78rem;
- color: #5b4c34;
+.focus-stack {
+ display: grid;
+ gap: 14px;
+}
+
+.focus-block {
+ display: grid;
+ gap: 8px;
+}
+
+.focus-value {
+ font-size: 1.4rem;
+}
+
+.empty {
+ padding: 18px;
+ border-radius: 12px;
+ border: 1px dashed var(--border);
+ background: rgba(255, 255, 255, 0.02);
+ color: var(--text-dim);
}
.drawer {
position: fixed;
- top: 88px;
- right: 6vw;
- width: min(360px, 92vw);
- max-height: calc(100vh - 140px);
+ top: 24px;
+ right: 24px;
+ width: min(420px, calc(100vw - 32px));
+ max-height: calc(100vh - 48px);
overflow: auto;
- padding: 20px;
- border-radius: 20px;
- border: 1px solid var(--panel-border);
- background: #fffdf7;
- box-shadow: 0 32px 60px rgba(66, 45, 18, 0.22);
- z-index: 30;
+ padding: 18px;
+ border-radius: 14px;
+ border: 1px solid rgba(245, 166, 35, 0.2);
+ background: rgba(7, 10, 14, 0.97);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
+ z-index: 40;
}
.drawer-header {
@@ -735,33 +917,10 @@ h1 {
.drawer-eyebrow {
margin: 0 0 6px;
+ font-size: 0.68rem;
+ color: var(--accent);
text-transform: uppercase;
- letter-spacing: 0.3em;
- font-size: 0.65rem;
- color: #6f5b39;
-}
-
-.drawer h3 {
- margin: 0 0 4px;
- font-size: 1.1rem;
-}
-
-.drawer-subtitle {
- margin: 0;
- color: #6f5b39;
- font-size: 0.8rem;
-}
-
-.drawer-close {
- border: 1px solid rgba(111, 91, 57, 0.35);
- border-radius: 999px;
- padding: 6px 12px;
- background: rgba(111, 91, 57, 0.08);
- color: #6f5b39;
- font-size: 0.7rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- cursor: pointer;
+ letter-spacing: 0.14em;
}
.drawer-meta {
@@ -771,169 +930,30 @@ h1 {
margin-bottom: 16px;
}
-.drawer-chip {
- padding: 2px 8px;
- border-radius: 999px;
- border: 1px solid rgba(111, 91, 57, 0.35);
- background: rgba(111, 91, 57, 0.08);
- font-size: 0.7rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
-}
-
.drawer-section {
+ display: grid;
+ gap: 10px;
margin-bottom: 18px;
}
.drawer-section h4 {
- margin: 0 0 10px;
- font-size: 0.85rem;
+ margin: 0;
+ color: var(--text-faint);
text-transform: uppercase;
- letter-spacing: 0.22em;
- color: #6f5b39;
+ letter-spacing: 0.12em;
+ font-size: 0.72rem;
}
.drawer-list {
display: grid;
- gap: 12px;
+ gap: 10px;
}
.drawer-row {
padding: 12px 14px;
- border-radius: 14px;
- border: 1px solid rgba(217, 205, 184, 0.6);
- background: rgba(255, 255, 255, 0.75);
-}
-
-.drawer-row-title {
- font-weight: 600;
- margin-bottom: 6px;
-}
-
-.drawer-row-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- font-size: 0.75rem;
- color: #5b4c34;
-}
-
-.drawer-note {
- margin: 8px 0 0;
- font-size: 0.72rem;
- color: #5b4c34;
-}
-
-.drawer-empty {
- margin: 0;
- font-size: 0.78rem;
- color: #6f5b39;
-}
-
-.alert-strips {
- display: grid;
- gap: 12px;
- margin-bottom: 16px;
- padding: 12px 14px;
- border-radius: 14px;
- border: 1px solid rgba(217, 205, 184, 0.6);
- background: rgba(255, 255, 255, 0.7);
-}
-
-.alert-strip-section {
- display: grid;
- gap: 6px;
-}
-
-.alert-strip-header {
- display: flex;
- justify-content: space-between;
- font-size: 0.7rem;
- color: #6f5b39;
- text-transform: uppercase;
- letter-spacing: 0.22em;
-}
-
-.alert-strip-bar {
- display: flex;
- height: 26px;
- border-radius: 999px;
- overflow: hidden;
- border: 1px solid rgba(217, 205, 184, 0.6);
- background: rgba(111, 91, 57, 0.08);
-}
-
-.strip-segment {
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.65rem;
- color: #fffdf7;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.alert-strip-bar .severity-high {
- background: rgba(196, 111, 42, 0.85);
- color: #3b1a09;
-}
-
-.alert-strip-bar .severity-medium {
- background: rgba(31, 74, 123, 0.8);
-}
-
-.alert-strip-bar .severity-low {
- background: rgba(47, 109, 79, 0.8);
-}
-
-.alert-strip-bar .direction-bullish {
- background: rgba(47, 109, 79, 0.85);
-}
-
-.alert-strip-bar .direction-bearish {
- background: rgba(196, 111, 42, 0.85);
- color: #3b1a09;
-}
-
-.alert-strip-bar .direction-neutral {
- background: rgba(111, 91, 57, 0.65);
-}
-
-.flow-meta span {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-
-.flag {
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 0.7rem;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- border: 1px solid rgba(47, 109, 79, 0.4);
- color: #2f6d4f;
- background: rgba(47, 109, 79, 0.12);
-}
-
-.flag-muted {
- border-color: rgba(111, 91, 57, 0.4);
- color: #6f5b39;
- background: rgba(111, 91, 57, 0.12);
-}
-
-.time {
- font-size: 0.85rem;
- color: #6f5b39;
- text-align: right;
-}
-
-.empty {
- padding: 24px;
- border-radius: 16px;
- background: rgba(255, 255, 255, 0.7);
- border: 1px dashed rgba(217, 205, 184, 0.8);
- color: #5b4c34;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.02);
}
@keyframes pulse {
@@ -941,30 +961,101 @@ h1 {
transform: scale(1);
}
50% {
- transform: scale(1.25);
+ transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
-@media (max-width: 720px) {
- .dashboard {
- padding: 36px 6vw 56px;
+@media (max-width: 1180px) {
+ .terminal-shell {
+ grid-template-columns: 1fr;
}
- .filter-bar {
+ .terminal-rail {
+ position: static;
+ height: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .shell-metrics {
+ margin-top: 0;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 980px) {
+ .page-grid-overview,
+ .page-grid-tape,
+ .page-grid-signals,
+ .page-grid-charts,
+ .page-grid-replay,
+ .overview-strip,
+ .replay-matrix,
+ .shell-metrics {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .page-grid-overview > :nth-child(1),
+ .page-grid-tape > :nth-child(1),
+ .page-grid-replay > :nth-child(1) {
+ grid-column: auto;
+ grid-row: auto;
+ }
+
+ .page-grid-overview > :not(:first-child),
+ .page-grid-signals > .terminal-pane,
+ .page-grid-replay > :not(:first-child),
+ .page-grid-tape > :first-child,
+ .page-grid-tape > :not(:first-child),
+ .page-grid-charts > :last-child {
+ height: auto;
+ }
+
+ .page-grid-signals {
+ grid-template-rows: auto;
+ min-height: 0;
+ }
+
+ .terminal-topbar {
+ position: static;
+ height: auto;
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+@media (max-width: 720px) {
+ .terminal-content {
+ padding: 18px 14px 22px;
+ }
+
+ .page-header,
+ .terminal-pane-head,
+ .chart-controls,
+ .card-controls,
+ .terminal-pane-actions {
flex-direction: column;
align-items: flex-start;
}
- .filter-controls {
- width: 100%;
+ .terminal-pane-title-row {
flex-direction: column;
- align-items: stretch;
+ align-items: flex-start;
}
- .filter-input {
+ .terminal-topbar-controls {
+ width: 100%;
+ }
+
+ .terminal-filter {
+ width: 100%;
+ flex-basis: 100%;
+ }
+
+ .terminal-input {
width: 100%;
}
@@ -977,102 +1068,14 @@ h1 {
text-align: left;
}
+ .chart-surface {
+ height: 320px;
+ }
+
.drawer {
position: static;
- width: 100%;
+ width: auto;
max-height: none;
- }
-
- .list {
- min-height: 360px;
- }
-}
-
-@media (max-width: 1100px) {
- .cards {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .card-chart,
- .card-options,
- .card-equities,
- .card-flow,
- .card-alerts,
- .card-classifiers,
- .card-dark {
- grid-column: span 2;
- }
-}
-
-@media (max-width: 860px) {
- .cards {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .card-chart,
- .card-options,
- .card-equities,
- .card-flow,
- .card-alerts,
- .card-classifiers,
- .card-dark {
- grid-column: span 1;
- }
-}
-.card-flow .row,
-.card-alerts .row,
-.card-classifiers .row,
-.card-dark .row {
- padding: 12px 14px;
-}
-
-.card-flow .meta,
-.card-alerts .meta,
-.card-classifiers .meta,
-.card-dark .meta {
- font-size: 0.78rem;
-}
-
-.card-flow .note,
-.card-alerts .note,
-.card-classifiers .note,
-.card-dark .note {
- font-size: 0.72rem;
-}
-
-.card-flow,
-.card-alerts,
-.card-classifiers,
-.card-dark {
- display: flex;
- flex-direction: column;
- height: 960px;
- overflow: hidden;
-}
-
-.card-body {
- display: flex;
- flex-direction: column;
- flex: 1 1 0;
- min-height: 0;
- gap: 16px;
-}
-
-.card-body .list {
- flex: 1 1 0;
- min-height: 0;
- height: auto;
-}
-
-@media (max-width: 720px) {
- .card-flow,
- .card-alerts,
- .card-classifiers,
- .card-dark {
- height: 780px;
- }
-
- .chart-surface {
- height: 280px;
+ margin-top: 14px;
}
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index c27753d..ea8e34b 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,9 +1,29 @@
import "./globals.css";
import type { ReactNode } from "react";
+import { IBM_Plex_Mono, IBM_Plex_Sans, Quantico } from "next/font/google";
+import { TerminalAppShell } from "./terminal";
+
+const display = Quantico({
+ subsets: ["latin"],
+ weight: ["400", "700"],
+ variable: "--font-display"
+});
+
+const sans = IBM_Plex_Sans({
+ subsets: ["latin"],
+ weight: ["400", "500", "600"],
+ variable: "--font-sans"
+});
+
+const mono = IBM_Plex_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500"],
+ variable: "--font-mono"
+});
export const metadata = {
- title: "Islandflow",
- description: "Realtime options flow & off-exchange analysis"
+ title: "Islandflow Terminal",
+ description: "Realtime options flow and off-exchange analysis terminal"
};
type RootLayoutProps = {
@@ -13,7 +33,9 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
return (
-
{children}
+
+ {children}
+
);
}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 579ad2c..a6807b8 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,3764 +1,5 @@
-"use client";
+import { OverviewRoute } from "./terminal";
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import type {
- AlertEvent,
- ClassifierHitEvent,
- EquityCandle,
- EquityPrint,
- EquityPrintJoin,
- FlowPacket,
- InferredDarkEvent,
- OptionNBBO,
- OptionPrint
-} from "@islandflow/types";
-import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts";
-
-const MAX_ITEMS = 500;
-const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS);
-const NBBO_MAX_AGE_MS_SAFE =
- Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000;
-const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]);
-const CANDLE_INTERVALS = [
- { label: "1m", ms: 60000 },
- { label: "5m", ms: 300000 }
-];
-
-type CandlestickSeries = ReturnType;
-
-type EquityOverlayPoint = {
- ts: number;
- price: number;
- size: number;
- offExchangeFlag: boolean;
-};
-
-type ChartCandle = {
- time: UTCTimestamp;
- open: number;
- high: number;
- low: number;
- close: number;
-};
-
-const formatIntervalLabel = (intervalMs: number): string => {
- const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs);
- if (match) {
- return match.label;
- }
- if (intervalMs >= 60000) {
- return `${Math.round(intervalMs / 60000)}m`;
- }
- if (intervalMs >= 1000) {
- return `${Math.round(intervalMs / 1000)}s`;
- }
- return `${intervalMs}ms`;
-};
-
-const toChartTime = (ts: number): UTCTimestamp => {
- return Math.floor(ts / 1000) as UTCTimestamp;
-};
-
-type ChartTimeLike = number | string | { year: number; month: number; day: number };
-
-const chartTimeToMs = (value: ChartTimeLike): number | null => {
- if (typeof value === "number") {
- return Math.floor(value * 1000);
- }
-
- if (typeof value === "string") {
- const parsed = Date.parse(value);
- return Number.isFinite(parsed) ? parsed : null;
- }
-
- if (value && typeof value === "object") {
- const { year, month, day } = value;
- if (
- Number.isFinite(year) &&
- Number.isFinite(month) &&
- Number.isFinite(day) &&
- year >= 1970 &&
- month >= 1 &&
- month <= 12 &&
- day >= 1 &&
- day <= 31
- ) {
- return Date.UTC(year, month - 1, day);
- }
- }
-
- return null;
-};
-
-const toChartCandle = (candle: EquityCandle): ChartCandle => {
- return {
- time: toChartTime(candle.ts),
- open: candle.open,
- high: candle.high,
- low: candle.low,
- close: candle.close
- };
-};
-
-const clamp = (value: number, min: number, max: number): number => {
- if (!Number.isFinite(value)) {
- return min;
- }
- return Math.max(min, Math.min(max, value));
-};
-
-const sampleToLimit = (items: T[], limit: number): T[] => {
- if (items.length <= limit) {
- return items;
- }
-
- const safeLimit = Math.max(1, Math.floor(limit));
- const step = Math.ceil(items.length / safeLimit);
- const sampled: T[] = [];
- for (let idx = 0; idx < items.length; idx += step) {
- sampled.push(items[idx]);
- }
-
- return sampled;
-};
-
-const readErrorDetail = async (response: Response): Promise => {
- const text = await response.text();
- if (!text) {
- return "";
- }
- try {
- const payload = JSON.parse(text) as {
- detail?: string;
- error?: string;
- message?: string;
- };
- return payload.detail ?? payload.error ?? payload.message ?? text;
- } catch {
- return text;
- }
-};
-
-type WsStatus = "connecting" | "connected" | "disconnected";
-
-type TapeMode = "live" | "replay";
-
-type MessageType =
- | "option-print"
- | "option-nbbo"
- | "equity-print"
- | "equity-candle"
- | "equity-join"
- | "flow-packet"
- | "inferred-dark"
- | "classifier-hit"
- | "alert";
-
-type StreamMessage = {
- type: MessageType;
- payload: T;
-};
-
-type ReplayCursor = {
- ts: number;
- seq: number;
-};
-
-type ReplayResponse = {
- data: T[];
- next: ReplayCursor | null;
-};
-
-const inferTracePrefix = (traceId: string): string => {
- const match = traceId.match(/^(.*)-\d+$/);
- return match ? match[1] : traceId;
-};
-
-const extractTracePrefix = (item: T): string | null => {
- const traceId = (item as { trace_id?: string }).trace_id;
- if (!traceId) {
- return null;
- }
- return inferTracePrefix(traceId);
-};
-
-const extractReplaySource = (item: T): string | null => {
- const prefix = extractTracePrefix(item);
- if (!prefix) {
- return null;
- }
-
- const normalized = prefix.toLowerCase();
- if (normalized.startsWith("synthetic")) {
- return "synthetic";
- }
- if (normalized.startsWith("databento")) {
- return "databento";
- }
- if (normalized.startsWith("alpaca")) {
- return "alpaca";
- }
- if (normalized.startsWith("ibkr")) {
- return "ibkr";
- }
-
- return prefix;
-};
-
-type SortableItem = {
- ts?: number;
- source_ts?: number;
- ingest_ts?: number;
- seq?: number;
- trace_id?: string;
- id?: string;
-};
-
-const extractSortTs = (item: SortableItem): number =>
- item.ts ?? item.source_ts ?? item.ingest_ts ?? 0;
-
-const extractSortSeq = (item: SortableItem): number => item.seq ?? 0;
-
-const buildItemKey = (item: SortableItem): string | null => {
- if (item.trace_id) {
- return `${item.trace_id}:${item.seq ?? ""}`;
- }
-
- if (item.id) {
- return `id:${item.id}`;
- }
-
- return null;
-};
-
-const mergeNewest = (incoming: T[], existing: T[]): T[] => {
- const combined = [...incoming, ...existing];
- if (combined.length === 0) {
- return combined;
- }
-
- const seen = new Set();
- const deduped: T[] = [];
-
- for (const item of combined) {
- const key = buildItemKey(item);
- if (key) {
- if (seen.has(key)) {
- continue;
- }
- seen.add(key);
- }
- deduped.push(item);
- }
-
- deduped.sort((a, b) => {
- const delta = extractSortTs(b) - extractSortTs(a);
- if (delta !== 0) {
- return delta;
- }
- return extractSortSeq(b) - extractSortSeq(a);
- });
-
- return deduped.slice(0, MAX_ITEMS);
-};
-
-type TapeState = {
- status: WsStatus;
- items: T[];
- lastUpdate: number | null;
- replayTime: number | null;
- replayComplete: boolean;
- paused: boolean;
- dropped: number;
- togglePause: () => void;
-};
-
-const buildWsUrl = (path: string): string => {
- const envBase = process.env.NEXT_PUBLIC_API_URL;
-
- if (envBase) {
- const url = new URL(envBase);
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
- url.pathname = path;
- url.search = "";
- url.hash = "";
- return url.toString();
- }
-
- const { protocol, hostname } = window.location;
- const wsProtocol = protocol === "https:" ? "wss" : "ws";
- const isLocal = LOCAL_HOSTS.has(hostname);
- const host = isLocal ? `${hostname}:4000` : window.location.host;
-
- return `${wsProtocol}://${host}${path}`;
-};
-
-const buildApiUrl = (path: string): string => {
- const envBase = process.env.NEXT_PUBLIC_API_URL;
-
- if (envBase) {
- const url = new URL(envBase);
- const secure = url.protocol === "https:" || url.protocol === "wss:";
- url.protocol = secure ? "https:" : "http:";
- url.pathname = path;
- url.search = "";
- url.hash = "";
- return url.toString();
- }
-
- const { protocol, hostname } = window.location;
- const httpProtocol = protocol === "https:" ? "https" : "http";
- const isLocal = LOCAL_HOSTS.has(hostname);
- const host = isLocal ? `${hostname}:4000` : window.location.host;
-
- return `${httpProtocol}://${host}${path}`;
-};
-
-const formatPrice = (price: number): string => {
- if (!Number.isFinite(price)) {
- return "0.00";
- }
- return price.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- });
-};
-
-const formatSize = (size: number): string => {
- return size.toLocaleString();
-};
-
-const formatTime = (ts: number): string => {
- return new Date(ts).toLocaleTimeString();
-};
-
-const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`;
-
-const formatPct = (value: number): string => `${Math.round(value * 100)}%`;
-
-const formatUsd = (value: number): string => {
- if (!Number.isFinite(value)) {
- return "0.00";
- }
- return value.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- });
-};
-
-const normalizeContractId = (value: string): string => value.trim();
-
-const formatContractLabel = (value: string): string => {
- const normalized = normalizeContractId(value);
- if (!normalized) {
- return "Unknown contract";
- }
- if (/^\d+$/.test(normalized)) {
- return `Instrument ${normalized}`;
- }
- return normalized;
-};
-
-const formatDateTime = (ts: number): string => {
- const date = new Date(ts);
- return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
-};
-
-const humanizeClassifierId = (value: string): string => {
- if (!value) {
- return "Classifier";
- }
-
- return value
- .split("_")
- .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
- .join(" ");
-};
-
-const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" => {
- const normalized = value.toLowerCase();
- if (normalized === "bullish" || normalized === "bearish" || normalized === "neutral") {
- return normalized;
- }
- return "neutral";
-};
-
-const extractUnderlying = (contractId: string): string => {
- const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/);
- if (match?.[1]) {
- return match[1].toUpperCase();
- }
- return contractId.split("-")[0]?.toUpperCase() ?? contractId.toUpperCase();
-};
-
-const extractEquityTraceFromJoin = (joinId: string): string | null => {
- const match = joinId.match(/^equityjoin:(.+)$/);
- return match?.[1] ?? null;
-};
-
-const inferDarkUnderlying = (
- event: InferredDarkEvent,
- equityPrints: Map,
- equityJoins: Map
-): string | null => {
- for (const ref of event.evidence_refs) {
- const join = equityJoins.get(ref);
- if (!join) {
- continue;
- }
- const underlying = join.features.underlying_id;
- if (typeof underlying === "string" && underlying.length > 0) {
- return underlying.toUpperCase();
- }
- }
-
- const match = event.trace_id.match(/^dark:(?:stealth_accumulation|distribution):([^:]+):/);
- if (match?.[1]) {
- return match[1].toUpperCase();
- }
-
- for (const ref of event.evidence_refs) {
- const traceId = extractEquityTraceFromJoin(ref);
- if (!traceId) {
- continue;
- }
- const print = equityPrints.get(traceId);
- if (print) {
- return print.underlying_id.toUpperCase();
- }
- }
-
- return null;
-};
-
-const parseNumber = (value: unknown, fallback: number): number => {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
-
- if (typeof value === "string") {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
- }
-
- return fallback;
-};
-
-const parseBoolean = (value: unknown, fallback = false): boolean => {
- if (typeof value === "boolean") {
- return value;
- }
- if (typeof value === "number") {
- return value !== 0;
- }
- if (typeof value === "string") {
- const normalized = value.trim().toLowerCase();
- if (["true", "1", "yes", "on"].includes(normalized)) {
- return true;
- }
- if (["false", "0", "no", "off"].includes(normalized)) {
- return false;
- }
- }
- return fallback;
-};
-
-const getJoinString = (join: EquityPrintJoin, key: string): string | null => {
- const value = join.features[key];
- return typeof value === "string" ? value : null;
-};
-
-const getJoinNumber = (join: EquityPrintJoin, key: string, fallback = Number.NaN): number => {
- return parseNumber(join.features[key], fallback);
-};
-
-const getJoinBoolean = (join: EquityPrintJoin, key: string): boolean => {
- return parseBoolean(join.features[key], false);
-};
-
-type NbboSide = "AA" | "A" | "B" | "BB";
-
-const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): NbboSide | null => {
- if (!quote || !Number.isFinite(price)) {
- return null;
- }
-
- const bid = quote.bid;
- const ask = quote.ask;
- if (!Number.isFinite(bid) || !Number.isFinite(ask) || ask <= 0) {
- return null;
- }
-
- const spread = Math.max(0, ask - bid);
- const epsilon = Math.max(0.01, spread * 0.05);
-
- if (price > ask + epsilon) {
- return "AA";
- }
- if (price >= ask - epsilon) {
- return "A";
- }
- if (price < bid - epsilon) {
- return "BB";
- }
- if (price <= bid + epsilon) {
- return "B";
- }
-
- const mid = (bid + ask) / 2;
- return price >= mid ? "A" : "B";
-};
-
-type ListScrollState = {
- listRef: React.RefObject;
- isAtTop: boolean;
- isAtTopRef: React.MutableRefObject;
- missed: number;
- resumeTick: number;
- onNewItems: (count: number) => void;
- jumpToTop: () => void;
-};
-
-const useListScroll = (): ListScrollState => {
- const listRef = useRef(null);
- const [isAtTop, setIsAtTop] = useState(true);
- const [missed, setMissed] = useState(0);
- const [resumeTick, setResumeTick] = useState(0);
- const isAtTopRef = useRef(true);
- const prevAtTopRef = useRef(true);
-
- useEffect(() => {
- isAtTopRef.current = isAtTop;
- }, [isAtTop]);
-
- const updateScrollState = useCallback(() => {
- const el = listRef.current;
- if (!el) {
- return;
- }
-
- const atTop = el.scrollTop <= 2;
-
- if (atTop && !prevAtTopRef.current) {
- setResumeTick((prev) => prev + 1);
- }
-
- prevAtTopRef.current = atTop;
- isAtTopRef.current = atTop;
- setIsAtTop(atTop);
-
- if (atTop) {
- setMissed(0);
- }
- }, [isAtTopRef]);
-
- useEffect(() => {
- const el = listRef.current;
- if (!el) {
- return;
- }
-
- const onScroll = () => {
- updateScrollState();
- };
-
- updateScrollState();
- el.addEventListener("scroll", onScroll);
-
- return () => {
- el.removeEventListener("scroll", onScroll);
- };
- }, [updateScrollState]);
-
- const onNewItems = useCallback((count: number) => {
- if (count <= 0) {
- return;
- }
-
- if (isAtTopRef.current) {
- setMissed(0);
- return;
- }
-
- setMissed((prev) => prev + count);
- }, []);
-
- const jumpToTop = useCallback(() => {
- const el = listRef.current;
- if (!el) {
- return;
- }
-
- isAtTopRef.current = true;
- el.scrollTop = 0;
- updateScrollState();
- }, [isAtTopRef, listRef, updateScrollState]);
-
- return {
- listRef,
- isAtTop,
- isAtTopRef,
- missed,
- resumeTick,
- onNewItems,
- jumpToTop
- };
-};
-
-const useScrollAnchor = (
- listRef: React.RefObject,
- isAtTopRef: React.MutableRefObject
-) => {
- const pendingRef = useRef<{ height: number } | null>(null);
-
- const capture = useCallback(() => {
- if (isAtTopRef.current) {
- pendingRef.current = null;
- return;
- }
-
- const el = listRef.current;
- if (!el) {
- return;
- }
-
- pendingRef.current = {
- height: el.scrollHeight
- };
- }, [isAtTopRef, listRef]);
-
- const apply = useCallback(() => {
- const pending = pendingRef.current;
- if (!pending) {
- return;
- }
-
- const el = listRef.current;
- if (!el) {
- return;
- }
-
- if (isAtTopRef.current) {
- pendingRef.current = null;
- return;
- }
-
- const delta = el.scrollHeight - pending.height;
- if (delta !== 0) {
- el.scrollTop = Math.max(0, el.scrollTop + delta);
- }
- pendingRef.current = null;
- }, [isAtTopRef, listRef]);
-
- return { capture, apply };
-};
-
-const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => {
- if (paused) {
- return "Paused";
- }
-
- if (mode === "replay") {
- return status === "disconnected" ? "Replay Down" : "Replay";
- }
-
- switch (status) {
- case "connected":
- return "Live";
- case "connecting":
- return "Connecting";
- case "disconnected":
- default:
- return "Disconnected";
- }
-};
-
-type TapeConfig = {
- mode: TapeMode;
- wsPath: string;
- replayPath: string;
- latestPath?: string;
- expectedType: MessageType;
- batchSize?: number;
- pollMs?: number;
- captureScroll?: () => void;
- onNewItems?: (count: number) => void;
- getItemTs?: (item: T) => number;
- getReplayKey?: (item: T) => string | null;
- replaySourceKey?: string | null;
- onReplaySourceKey?: (key: string | null) => void;
-};
-
-const useTape = (
- config: TapeConfig
-): TapeState => {
- const { mode, wsPath, replayPath, expectedType, latestPath, onNewItems, captureScroll } = config;
- const batchSize = config.batchSize ?? 40;
- const pollMs = config.pollMs ?? 1000;
- const getItemTs = config.getItemTs ?? extractSortTs;
- const getReplayKey = config.getReplayKey ?? extractTracePrefix;
- const replaySourceKey = config.replaySourceKey ?? null;
- const onReplaySourceKey = config.onReplaySourceKey;
- const [status, setStatus] = useState("connecting");
- const [items, setItems] = useState([]);
- const [lastUpdate, setLastUpdate] = useState(null);
- const [replayTime, setReplayTime] = useState(null);
- const [replayComplete, setReplayComplete] = useState(false);
- const [paused, setPaused] = useState(false);
- const [dropped, setDropped] = useState(0);
- const reconnectRef = useRef(null);
- const socketRef = useRef(null);
- const cursorRef = useRef({ ts: 0, seq: 0 });
- const replayEndRef = useRef(null);
- const replayCompleteRef = useRef(false);
- const replaySourceRef = useRef(null);
- const replaySourceNotifiedRef = useRef(null);
- const emptyPollsRef = useRef(0);
- const pausedRef = useRef(paused);
- const pendingRef = useRef([]);
- const pendingCountRef = useRef(0);
- const flushHandleRef = useRef(null);
-
- useEffect(() => {
- pausedRef.current = paused;
- }, [paused]);
-
- const cancelFlush = useCallback(() => {
- if (flushHandleRef.current !== null) {
- cancelAnimationFrame(flushHandleRef.current);
- flushHandleRef.current = null;
- }
- }, []);
-
- const scheduleFlush = useCallback(() => {
- if (flushHandleRef.current !== null) {
- return;
- }
-
- flushHandleRef.current = requestAnimationFrame(() => {
- flushHandleRef.current = null;
- const buffered = pendingRef.current;
- if (buffered.length === 0) {
- return;
- }
- pendingRef.current = [];
-
- const pendingCount = pendingCountRef.current;
- pendingCountRef.current = 0;
-
- if (onNewItems && pendingCount > 0) {
- onNewItems(pendingCount);
- }
-
- if (captureScroll) {
- captureScroll();
- }
-
- setItems((prev) => mergeNewest(buffered, prev));
- setLastUpdate(Date.now());
- });
- }, [captureScroll, onNewItems]);
-
- const togglePause = useCallback(() => {
- setPaused((prev) => {
- const next = !prev;
- if (!next) {
- setDropped(0);
- }
- return next;
- });
- }, []);
-
- useEffect(() => {
- setItems([]);
- setLastUpdate(null);
- setReplayTime(null);
- setReplayComplete(false);
- replayCompleteRef.current = false;
- replaySourceRef.current = null;
- replaySourceNotifiedRef.current = null;
- emptyPollsRef.current = 0;
- setDropped(0);
- setStatus("connecting");
- cursorRef.current = { ts: 0, seq: 0 };
- pendingRef.current = [];
- pendingCountRef.current = 0;
- cancelFlush();
- }, [mode, replaySourceKey, cancelFlush]);
-
- useEffect(() => {
- if (mode !== "replay" || !latestPath) {
- replayEndRef.current = null;
- return;
- }
-
- let active = true;
- replayEndRef.current = null;
- setReplayComplete(false);
- replayCompleteRef.current = false;
-
- const fetchReplayEnd = async () => {
- try {
- const url = new URL(buildApiUrl(latestPath));
- url.searchParams.set("limit", "1");
- if (replaySourceKey) {
- url.searchParams.set("source", replaySourceKey);
- }
- const response = await fetch(url.toString());
- if (!response.ok) {
- throw new Error(`Replay baseline failed with ${response.status}`);
- }
-
- const payload = (await response.json()) as { data?: T[] };
- const latest = payload.data?.[0];
- if (active && latest) {
- replayEndRef.current = getItemTs(latest);
- }
- } catch (error) {
- console.warn("Failed to load replay end cursor", error);
- }
- };
-
- void fetchReplayEnd();
-
- return () => {
- active = false;
- };
- }, [mode, latestPath, getItemTs, replaySourceKey]);
-
- useEffect(() => {
- if (mode !== "live") {
- return;
- }
-
- let active = true;
-
- const connect = () => {
- if (!active) {
- return;
- }
-
- setStatus("connecting");
-
- const socket = new WebSocket(buildWsUrl(wsPath));
- socketRef.current = socket;
-
- socket.onopen = () => {
- if (!active) {
- return;
- }
- setStatus("connected");
- };
-
- socket.onmessage = (event) => {
- if (!active) {
- return;
- }
-
- try {
- const message = JSON.parse(event.data) as StreamMessage;
- if (!message || message.type !== expectedType) {
- return;
- }
-
- if (pausedRef.current) {
- setDropped((prev) => prev + 1);
- setLastUpdate(Date.now());
- return;
- }
-
- pendingRef.current.push(message.payload);
- pendingCountRef.current += 1;
- scheduleFlush();
- } catch (error) {
- console.warn("Failed to parse websocket payload", error);
- }
- };
-
- socket.onclose = () => {
- if (!active) {
- return;
- }
-
- setStatus("disconnected");
- reconnectRef.current = window.setTimeout(() => {
- connect();
- }, 1000);
- };
-
- socket.onerror = () => {
- if (!active) {
- return;
- }
-
- setStatus("disconnected");
- socket.close();
- };
- };
-
- connect();
-
- return () => {
- active = false;
- cancelFlush();
- if (reconnectRef.current !== null) {
- window.clearTimeout(reconnectRef.current);
- }
- if (socketRef.current) {
- socketRef.current.close();
- }
- };
- }, [mode, wsPath, expectedType, scheduleFlush, cancelFlush]);
-
- useEffect(() => {
- if (mode !== "replay") {
- return;
- }
-
- let active = true;
-
- const poll = async () => {
- if (!active || pausedRef.current) {
- return;
- }
-
- if (replayCompleteRef.current) {
- return;
- }
-
- try {
- let keepPolling = true;
-
- while (keepPolling && active && !pausedRef.current) {
- const replayEnd = replayEndRef.current;
- const cursor = cursorRef.current;
-
- if (replayEnd !== null && cursor.ts >= replayEnd) {
- replayCompleteRef.current = true;
- setReplayComplete(true);
- setStatus("disconnected");
- return;
- }
-
- const url = new URL(buildApiUrl(replayPath));
- url.searchParams.set("after_ts", cursor.ts.toString());
- url.searchParams.set("after_seq", cursor.seq.toString());
- url.searchParams.set("limit", batchSize.toString());
- const desiredSource = replaySourceKey ?? replaySourceRef.current;
- if (desiredSource) {
- url.searchParams.set("source", desiredSource);
- }
-
- const response = await fetch(url.toString());
- if (!response.ok) {
- throw new Error(`Replay request failed with ${response.status}`);
- }
-
- const payload = (await response.json()) as ReplayResponse;
-
- let sourcePrefix = replaySourceRef.current;
- if (replaySourceKey) {
- if (sourcePrefix !== replaySourceKey) {
- sourcePrefix = replaySourceKey;
- replaySourceRef.current = replaySourceKey;
- }
- } else if (!sourcePrefix) {
- const firstWithTrace = payload.data.find((item) => getReplayKey(item));
- if (firstWithTrace) {
- sourcePrefix = getReplayKey(firstWithTrace);
- replaySourceRef.current = sourcePrefix ?? null;
- }
- }
-
- if (onReplaySourceKey && sourcePrefix && replaySourceNotifiedRef.current !== sourcePrefix) {
- replaySourceNotifiedRef.current = sourcePrefix;
- onReplaySourceKey(sourcePrefix);
- }
-
- const filtered = sourcePrefix
- ? payload.data.filter((item) => getReplayKey(item) === sourcePrefix)
- : payload.data;
-
- const hasForeign =
- sourcePrefix &&
- payload.data.some((item) => {
- const prefix = getReplayKey(item);
- return prefix !== null && prefix !== sourcePrefix;
- });
-
- if (filtered.length > 0) {
- const nextItems = [...filtered].reverse();
- pendingRef.current.push(...nextItems);
- pendingCountRef.current += nextItems.length;
- scheduleFlush();
- const last = filtered.at(-1);
- if (last) {
- const lastTs = getItemTs(last);
- setReplayTime(lastTs);
- if (replayEnd !== null && lastTs >= replayEnd) {
- cursorRef.current = { ts: lastTs, seq: last.seq };
- replayCompleteRef.current = true;
- setReplayComplete(true);
- setStatus("disconnected");
- return;
- }
- }
- emptyPollsRef.current = 0;
- } else if (sourcePrefix) {
- emptyPollsRef.current += 1;
- }
-
- if (payload.next) {
- cursorRef.current = payload.next;
- }
-
- setStatus("connected");
- keepPolling = filtered.length === batchSize;
-
- if (keepPolling) {
- await new Promise((resolve) => setTimeout(resolve, 0));
- }
-
- if (!replaySourceKey && hasForeign) {
- replayCompleteRef.current = true;
- setReplayComplete(true);
- setStatus("disconnected");
- return;
- }
-
- if (sourcePrefix && emptyPollsRef.current >= 3) {
- replayCompleteRef.current = true;
- setReplayComplete(true);
- setStatus("disconnected");
- return;
- }
- }
- } catch (error) {
- console.warn("Replay poll failed", error);
- setStatus("disconnected");
- }
- };
-
- void poll();
- const interval = window.setInterval(poll, pollMs);
-
- return () => {
- active = false;
- window.clearInterval(interval);
- cancelFlush();
- };
- }, [
- mode,
- replayPath,
- batchSize,
- pollMs,
- scheduleFlush,
- cancelFlush,
- getItemTs,
- getReplayKey,
- replaySourceKey,
- onReplaySourceKey
- ]);
-
- return {
- status,
- items,
- lastUpdate,
- replayTime,
- replayComplete,
- paused,
- dropped,
- togglePause
- };
-};
-
-const useLiveStream = (
- config: {
- enabled: boolean;
- wsPath: string;
- expectedType: MessageType;
- onNewItems?: (count: number) => void;
- captureScroll?: () => void;
- shouldHold?: () => boolean;
- resumeSignal?: number;
- }
-): TapeState => {
- const [status, setStatus] = useState(
- config.enabled ? "connecting" : "disconnected"
- );
- const [items, setItems] = useState([]);
- const [lastUpdate, setLastUpdate] = useState(null);
- const [replayTime] = useState(null);
- const [replayComplete] = useState(false);
- const [paused, setPaused] = useState(false);
- const [dropped, setDropped] = useState(0);
- const reconnectRef = useRef(null);
- const socketRef = useRef(null);
- const pausedRef = useRef(paused);
- const pendingRef = useRef([]);
- const pendingCountRef = useRef(0);
- const flushHandleRef = useRef(null);
- const holdRef = useRef([]);
-
- useEffect(() => {
- pausedRef.current = paused;
- }, [paused]);
-
- const cancelFlush = useCallback(() => {
- if (flushHandleRef.current !== null) {
- cancelAnimationFrame(flushHandleRef.current);
- flushHandleRef.current = null;
- }
- }, []);
-
- const scheduleFlush = useCallback(() => {
- if (flushHandleRef.current !== null) {
- return;
- }
-
- flushHandleRef.current = requestAnimationFrame(() => {
- flushHandleRef.current = null;
- const buffered = pendingRef.current;
- if (buffered.length === 0) {
- return;
- }
- pendingRef.current = [];
-
- const pendingCount = pendingCountRef.current;
- pendingCountRef.current = 0;
-
- if (config.onNewItems && pendingCount > 0) {
- config.onNewItems(pendingCount);
- }
-
- const shouldHold = config.shouldHold ? config.shouldHold() : false;
- if (!shouldHold && config.captureScroll) {
- config.captureScroll();
- }
-
- if (shouldHold) {
- holdRef.current = mergeNewest(buffered, holdRef.current);
- setLastUpdate(Date.now());
- return;
- }
-
- const nextBatch =
- holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered;
- holdRef.current = [];
-
- setItems((prev) => mergeNewest(nextBatch, prev));
- setLastUpdate(Date.now());
- });
- }, [config.captureScroll, config.onNewItems, config.shouldHold]);
-
- const togglePause = useCallback(() => {
- setPaused((prev) => {
- const next = !prev;
- if (!next) {
- setDropped(0);
- }
- return next;
- });
- }, []);
-
- useEffect(() => {
- if (!config.enabled) {
- setStatus("disconnected");
- setItems([]);
- setLastUpdate(null);
- pendingRef.current = [];
- pendingCountRef.current = 0;
- holdRef.current = [];
- cancelFlush();
- return;
- }
-
- let active = true;
-
- const connect = () => {
- if (!active) {
- return;
- }
-
- setStatus("connecting");
-
- const socket = new WebSocket(buildWsUrl(config.wsPath));
- socketRef.current = socket;
-
- socket.onopen = () => {
- if (!active) {
- return;
- }
- setStatus("connected");
- };
-
- socket.onmessage = (event) => {
- if (!active) {
- return;
- }
-
- try {
- const message = JSON.parse(event.data) as StreamMessage;
- if (!message || message.type !== config.expectedType) {
- return;
- }
-
- if (pausedRef.current) {
- setDropped((prev) => prev + 1);
- setLastUpdate(Date.now());
- return;
- }
-
- pendingRef.current.push(message.payload);
- pendingCountRef.current += 1;
- scheduleFlush();
- } catch (error) {
- console.warn("Failed to parse live stream payload", error);
- }
- };
-
- socket.onclose = () => {
- if (!active) {
- return;
- }
-
- setStatus("disconnected");
- reconnectRef.current = window.setTimeout(() => {
- connect();
- }, 1000);
- };
-
- socket.onerror = () => {
- if (!active) {
- return;
- }
-
- setStatus("disconnected");
- socket.close();
- };
- };
-
- connect();
-
- return () => {
- active = false;
- cancelFlush();
- if (reconnectRef.current !== null) {
- window.clearTimeout(reconnectRef.current);
- }
- if (socketRef.current) {
- socketRef.current.close();
- }
- };
- }, [config.enabled, config.expectedType, config.wsPath, scheduleFlush, cancelFlush]);
-
- useEffect(() => {
- if (config.resumeSignal === undefined) {
- return;
- }
- if (config.shouldHold && config.shouldHold()) {
- return;
- }
- if (holdRef.current.length === 0) {
- return;
- }
- setItems((prev) => mergeNewest(holdRef.current, prev));
- holdRef.current = [];
- setLastUpdate(Date.now());
- }, [config.resumeSignal, config.shouldHold]);
-
- return {
- status,
- items,
- lastUpdate,
- replayTime,
- replayComplete,
- paused,
- dropped,
- togglePause
- };
-};
-
-const useFlowStream = (
- enabled: boolean,
- onNewItems?: (count: number) => void,
- captureScroll?: () => void,
- shouldHold?: () => boolean,
- resumeSignal?: number
-): TapeState => {
- return useLiveStream({
- enabled,
- wsPath: "/ws/flow",
- expectedType: "flow-packet",
- onNewItems,
- captureScroll,
- shouldHold,
- resumeSignal
- });
-};
-
-type TapeStatusProps = {
- status: WsStatus;
- lastUpdate: number | null;
- replayTime: number | null;
- replayComplete: boolean;
- paused: boolean;
- dropped: number;
- mode: TapeMode;
- onTogglePause: () => void;
-};
-
-const TapeStatus = ({
- status,
- lastUpdate,
- replayTime,
- replayComplete,
- paused,
- dropped,
- mode,
- onTogglePause
-}: TapeStatusProps) => {
- const replayClass = mode === "replay" ? "status-replay" : "";
- const pausedClass = paused ? "status-paused" : "";
- const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
-
- return (
-
-
- {label}
- {lastUpdate ? (
- Updated {formatTime(lastUpdate)}
- ) : (
- Waiting for data
- )}
- {paused && dropped > 0 ? (
- {dropped} new while paused
- ) : null}
- {mode === "replay" ? (
-
- Replay time {replayTime ? formatTime(replayTime) : "—"}
-
- ) : null}
-
-
- );
-};
-
-type TapeControlsProps = {
- isAtTop: boolean;
- missed: number;
- onJump: () => void;
-};
-
-const TapeControls = ({ isAtTop, missed, onJump }: TapeControlsProps) => {
- const active = !isAtTop && missed > 0;
- return (
-
-
- {active ? `+${missed} new` : ""}
-
- );
-};
-
-type CandleChartProps = {
- ticker: string;
- intervalMs: number;
- mode: TapeMode;
- replayTime?: number | null;
- classifierHits: ClassifierHitEvent[];
- inferredDark: InferredDarkEvent[];
- onClassifierHitClick: (hit: ClassifierHitEvent) => void;
- onInferredDarkClick: (event: InferredDarkEvent) => void;
-};
-
-type MarkerAction =
- | { kind: "hit"; hit: ClassifierHitEvent }
- | { kind: "dark"; event: InferredDarkEvent };
-
-const CandleChart = ({
- ticker,
- intervalMs,
- mode,
- replayTime = null,
- classifierHits,
- inferredDark,
- onClassifierHitClick,
- onInferredDarkClick
-}: CandleChartProps) => {
- const containerRef = useRef(null);
- const chartRef = useRef(null);
- const seriesRef = useRef(null);
- const socketRef = useRef(null);
- const reconnectRef = useRef(null);
- const overlaySocketRef = useRef(null);
- const overlayReconnectRef = useRef(null);
- const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null);
-
- const markerLookupRef = useRef