Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a9ed015a | ||
|
|
38e42d10bb | ||
|
|
ae420246c8 | ||
|
|
7d9643bd1b | ||
|
|
99ff461d4d | ||
|
|
ce93ba456c | ||
|
|
3fc5fb8267 | ||
|
|
e07afe37f2 | ||
|
|
89453121a0 | ||
|
|
232868b9e7 | ||
|
|
8b32fe3994 | ||
|
|
250cbec7b7 | ||
|
|
dfac0db564 | ||
|
|
bb8e6d9d65 | ||
|
|
fd08d7a552 | ||
|
|
6ea2a29a7c | ||
|
|
51d5d9337a | ||
|
|
cd00a2e0fa | ||
|
|
b443fc787e | ||
|
|
4a6e6dea96 | ||
|
|
638b868649 | ||
|
|
433d0926e6 | ||
|
|
7e545140a2 | ||
|
|
e572df8558 | ||
|
|
a3afe3cb1b | ||
|
|
4841a7ad7c |
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
test-results/
|
test-results/
|
||||||
screenshots/
|
screenshots/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
119
CLAUDE.md
@@ -119,13 +119,114 @@ import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
|||||||
// Styles (once, at app root)
|
// Styles (once, at app root)
|
||||||
import '@cameleer/design-system/style.css'
|
import '@cameleer/design-system/style.css'
|
||||||
|
|
||||||
// Brand assets (static files — logo variants and favicon sizes)
|
// Brand assets (static files via ./assets/* export)
|
||||||
import logo from '@cameleer/design-system/logo' // full resolution PNG
|
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
|
||||||
import logo16 from '@cameleer/design-system/logo-16' // 16×16 favicon
|
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
|
||||||
import logo32 from '@cameleer/design-system/logo-32' // 32×32 favicon
|
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
|
||||||
import logo48 from '@cameleer/design-system/logo-48' // 48×48 taskbar
|
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
|
||||||
import logo180 from '@cameleer/design-system/logo-180' // Apple touch icon
|
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
|
||||||
import logo192 from '@cameleer/design-system/logo-192' // Android/PWA icon
|
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
|
||||||
import logo512 from '@cameleer/design-system/logo-512' // PWA splash, og:image
|
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
|
||||||
import logoSvg from '@cameleer/design-system/logo-svg' // SVG vector logo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **design-system** (1461 symbols, 2336 relationships, 23 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/design-system/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/design-system/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
@@ -54,8 +54,8 @@
|
|||||||
### "I need to display data"
|
### "I need to display data"
|
||||||
- Key metrics → **StatCard** (with optional sparkline/trend)
|
- Key metrics → **StatCard** (with optional sparkline/trend)
|
||||||
- Tabular data → **DataTable** (sortable, paginated)
|
- Tabular data → **DataTable** (sortable, paginated)
|
||||||
- Time series → **LineChart**, **AreaChart**
|
- Time series → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
- Categorical comparison → **BarChart**
|
- Categorical comparison → **ThemedChart** with `<Bar>`
|
||||||
- Inline trend → **Sparkline**
|
- Inline trend → **Sparkline**
|
||||||
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
|
||||||
- Event log → **EventFeed**
|
- Event log → **EventFeed**
|
||||||
@@ -138,7 +138,7 @@ FormField wraps any input (Input, Textarea, Select, RadioGroup, etc.)
|
|||||||
### KPI dashboard
|
### KPI dashboard
|
||||||
```
|
```
|
||||||
Row of StatCard components (each with optional Sparkline and trend)
|
Row of StatCard components (each with optional Sparkline and trend)
|
||||||
Below: charts (AreaChart, LineChart, BarChart)
|
Below: charts (ThemedChart with Line, Area, or Bar)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Master/detail management pattern
|
### Master/detail management pattern
|
||||||
@@ -175,57 +175,54 @@ StatCard strip (top, recalculates per scope)
|
|||||||
→ GroupCard grid (2-col for all, full-width for single app)
|
→ GroupCard grid (2-col for all, full-width for single app)
|
||||||
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
Each GroupCard: header (app name + count) + meta (TPS, routes) + instance rows
|
||||||
Instance rows: StatusDot + name + Badge + metrics
|
Instance rows: StatusDot + name + Badge + metrics
|
||||||
Single instance: expanded with LineChart panels
|
Single instance: expanded with ThemedChart panels
|
||||||
→ EventFeed (bottom, filtered by scope)
|
→ EventFeed (bottom, filtered by scope)
|
||||||
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/:instanceId
|
||||||
```
|
```
|
||||||
|
|
||||||
## Charting Strategy
|
## Charting Strategy
|
||||||
|
|
||||||
The design system includes built-in **AreaChart**, **BarChart**, **LineChart**, and **Sparkline** components for standard use cases. For advanced chart types (treemap, radar, heatmap, pie, sankey, etc.), consuming apps should use **Recharts** directly with the design system's theme preset for visual consistency.
|
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
**Recharts is the app's dependency, not the design system's.** The design system only exports a theme config object.
|
### Usage
|
||||||
|
|
||||||
### Setup in consuming app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install recharts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage with theme preset
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
import {
|
|
||||||
ResponsiveContainer, LineChart, Line,
|
|
||||||
CartesianGrid, XAxis, YAxis, Tooltip, Legend,
|
|
||||||
} from 'recharts'
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
<LineChart data={data}>
|
|
||||||
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
<XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
<YAxis {...rechartsTheme.yAxis} />
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
<Tooltip {...rechartsTheme.tooltip} />
|
</ThemedChart>
|
||||||
<Legend {...rechartsTheme.legend} />
|
|
||||||
<Line dataKey="value" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
|
||||||
<Line dataKey="other" stroke={CHART_COLORS[1]} strokeWidth={2} dot={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Exports
|
### ThemedChart Props
|
||||||
|
|
||||||
| Export | Description |
|
| Prop | Type | Default | Description |
|
||||||
|--------|-------------|
|
|------|------|---------|-------------|
|
||||||
| `rechartsTheme.cartesianGrid` | Dashed gridlines, subtle stroke |
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
| `rechartsTheme.xAxis` | Mono font axis ticks, subtle color |
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
| `rechartsTheme.yAxis` | Mono font axis ticks, no axis line |
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
| `rechartsTheme.tooltip` | Surface bg, border, shadow, monospace values |
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
| `rechartsTheme.legend` | Matching text size and color |
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
| `rechartsTheme.colors` | The 8 `CHART_COLORS` (CSS variables with light/dark support) |
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
| `ChartSeries` / `DataPoint` | Type interfaces for chart data |
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
|
||||||
## Component Index
|
## Component Index
|
||||||
|
|
||||||
@@ -234,11 +231,9 @@ import {
|
|||||||
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
|
||||||
| Alert | primitive | Page-level attention banner with variant colors |
|
| Alert | primitive | Page-level attention banner with variant colors |
|
||||||
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
|
||||||
| AreaChart | composite | Time series visualization with filled area |
|
|
||||||
| Avatar | primitive | User representation with initials and color |
|
| Avatar | primitive | User representation with initials and color |
|
||||||
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
|
||||||
| Badge | primitive | Labeled status indicator with semantic colors |
|
| Badge | primitive | Labeled status indicator with semantic colors |
|
||||||
| BarChart | composite | Categorical data comparison, optional stacking |
|
|
||||||
| Breadcrumb | composite | Navigation path showing current location |
|
| Breadcrumb | composite | Navigation path showing current location |
|
||||||
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
| Button | primitive | Action trigger (primary, secondary, danger, ghost) |
|
||||||
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
| ButtonGroup | primitive | Multi-select toggle group with optional colored dot indicators. Props: items (value, label, color?), value (Set), onChange |
|
||||||
@@ -266,7 +261,7 @@ import {
|
|||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
|
||||||
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
@@ -336,23 +331,24 @@ See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instruct
|
|||||||
|
|
||||||
## Brand Assets
|
## Brand Assets
|
||||||
|
|
||||||
The design system ships logo assets as static files via package exports. These are not React components — they resolve to file URLs when imported via a bundler.
|
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
|
||||||
|
|
||||||
| Export | Size | Use case |
|
| File | Size | Use case |
|
||||||
|--------|------|----------|
|
|------|------|----------|
|
||||||
| `@cameleer/design-system/logo` | Original | Full resolution for print/marketing |
|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
|
||||||
| `@cameleer/design-system/logo-16` | 16×16 | Browser tab favicon |
|
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
|
||||||
| `@cameleer/design-system/logo-32` | 32×32 | Standard favicon, bookmarks |
|
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
|
||||||
| `@cameleer/design-system/logo-48` | 48×48 | Windows taskbar |
|
| `cameleer3-48.png` | 48×48 | Windows taskbar |
|
||||||
| `@cameleer/design-system/logo-180` | 180×180 | Apple touch icon |
|
| `cameleer3-180.png` | 180×180 | Apple touch icon |
|
||||||
| `@cameleer/design-system/logo-192` | 192×192 | Android/PWA icon |
|
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
|
||||||
| `@cameleer/design-system/logo-512` | 512×512 | PWA splash, og:image |
|
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
|
||||||
| `@cameleer/design-system/logo-svg` | Vector | SVG logo for scalable usage |
|
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
|
||||||
|
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import logo from '@cameleer/design-system/logo-512'
|
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
|
||||||
<img src={logo} alt="Cameleer3" />
|
<img src={logo} alt="Cameleer3" />
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 941 B |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 342 KiB |
112
assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
845
docs/superpowers/plans/2026-04-12-recharts-migration.md
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
# Recharts Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the design system's hand-rolled SVG chart components with a single `ThemedChart` wrapper around Recharts, using Recharts-native data format.
|
||||||
|
|
||||||
|
**Architecture:** Add Recharts as a DS dependency. Create `ThemedChart` component that renders `ResponsiveContainer` + `ComposedChart` with pre-themed grid/axes/tooltip. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, `<ReferenceLine>`) as children. Delete old `LineChart/`, `AreaChart/`, `BarChart/`, `_chart-utils.ts`. Migrate the server UI's `AgentInstance.tsx` to the new API.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Recharts, CSS Modules, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Design System (`C:\Users\Hendrik\Documents\projects\design-system`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.tsx` | Wrapper component: ResponsiveContainer + ComposedChart + themed axes/grid/tooltip |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.tsx` | Custom tooltip with timestamp header + series values |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ChartTooltip.module.css` | Tooltip styles using DS tokens |
|
||||||
|
| Create | `src/design-system/composites/ThemedChart/ThemedChart.test.tsx` | Render tests for ThemedChart |
|
||||||
|
| Modify | `src/design-system/utils/rechartsTheme.ts` | Move `CHART_COLORS` definition here (was in `_chart-utils.ts`) |
|
||||||
|
| Modify | `src/design-system/composites/index.ts` | Remove old chart exports, add ThemedChart + Recharts re-exports |
|
||||||
|
| Modify | `COMPONENT_GUIDE.md` | Update charting strategy section |
|
||||||
|
| Modify | `package.json` | Add `recharts` dependency, bump version |
|
||||||
|
| Delete | `src/design-system/composites/LineChart/` | Old hand-rolled SVG line chart |
|
||||||
|
| Delete | `src/design-system/composites/AreaChart/` | Old hand-rolled SVG area chart |
|
||||||
|
| Delete | `src/design-system/composites/BarChart/` | Old hand-rolled SVG bar chart |
|
||||||
|
| Delete | `src/design-system/composites/_chart-utils.ts` | Old chart utilities |
|
||||||
|
|
||||||
|
**Server UI (`C:\Users\Hendrik\Documents\projects\cameleer3-server`):**
|
||||||
|
|
||||||
|
| Action | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Modify | `ui/src/pages/AgentInstance/AgentInstance.tsx` | Migrate 6 charts to ThemedChart + Recharts children |
|
||||||
|
| Modify | `ui/package.json` | Update `@cameleer/design-system` to new version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Recharts Dependency and Move CHART_COLORS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `src/design-system/utils/rechartsTheme.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install recharts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\design-system
|
||||||
|
npm install recharts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move CHART_COLORS into rechartsTheme.ts**
|
||||||
|
|
||||||
|
Replace the entire file `src/design-system/utils/rechartsTheme.ts` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
'var(--chart-6)',
|
||||||
|
'var(--chart-7)',
|
||||||
|
'var(--chart-8)',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
|
* chart styling. Used internally by ThemedChart and available for
|
||||||
|
* consumers composing Recharts directly.
|
||||||
|
*/
|
||||||
|
export const rechartsTheme = {
|
||||||
|
colors: CHART_COLORS,
|
||||||
|
|
||||||
|
cartesianGrid: {
|
||||||
|
stroke: 'var(--border-subtle)',
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
vertical: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: { stroke: 'var(--border-subtle)' },
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
tick: { fontSize: 9, fontFamily: 'var(--font-mono)', fill: 'var(--text-faint)' },
|
||||||
|
axisLine: false as const,
|
||||||
|
tickLine: false as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
contentStyle: {
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '6px 10px',
|
||||||
|
},
|
||||||
|
labelStyle: {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
cursor: { stroke: 'var(--text-faint)' },
|
||||||
|
},
|
||||||
|
|
||||||
|
legend: {
|
||||||
|
wrapperStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old chart components still import `CHART_COLORS` from `_chart-utils.ts` which still exists — they'll be deleted in Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json src/design-system/utils/rechartsTheme.ts
|
||||||
|
git commit -m "chore: add recharts dependency, move CHART_COLORS to rechartsTheme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create ChartTooltip Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ChartTooltip.module.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tooltip CSS**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create ChartTooltip component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ChartTooltip.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ChartTooltip component for ThemedChart"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create ThemedChart Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.tsx`
|
||||||
|
- Create: `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create ThemedChart component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write test**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/ThemedChart/ThemedChart.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/design-system/composites/ThemedChart/ThemedChart.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/ThemedChart/
|
||||||
|
git commit -m "feat: add ThemedChart wrapper component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Barrel Exports and Delete Old Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
- Delete: `src/design-system/composites/LineChart/`
|
||||||
|
- Delete: `src/design-system/composites/AreaChart/`
|
||||||
|
- Delete: `src/design-system/composites/BarChart/`
|
||||||
|
- Delete: `src/design-system/composites/_chart-utils.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update composites/index.ts**
|
||||||
|
|
||||||
|
Remove these lines:
|
||||||
|
```
|
||||||
|
export { AreaChart } from './AreaChart/AreaChart'
|
||||||
|
export { BarChart } from './BarChart/BarChart'
|
||||||
|
export { LineChart } from './LineChart/LineChart'
|
||||||
|
export { CHART_COLORS } from './_chart-utils'
|
||||||
|
export type { ChartSeries, DataPoint } from './_chart-utils'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add in their place:
|
||||||
|
```tsx
|
||||||
|
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar,
|
||||||
|
ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the rechartsTheme re-export from main index.ts**
|
||||||
|
|
||||||
|
In `src/design-system/index.ts`, remove this line (it's now re-exported via composites):
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with a targeted export that avoids double-exporting `CHART_COLORS`:
|
||||||
|
```tsx
|
||||||
|
export { rechartsTheme } from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — actually `composites/index.ts` already re-exports both `CHART_COLORS` and `rechartsTheme`. And `index.ts` does `export * from './composites'`. So the main `index.ts` line `export * from './utils/rechartsTheme'` would cause a duplicate export of both symbols. Remove it entirely:
|
||||||
|
|
||||||
|
Delete this line from `src/design-system/index.ts`:
|
||||||
|
```
|
||||||
|
export * from './utils/rechartsTheme'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete old chart directories and utilities**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf src/design-system/composites/LineChart
|
||||||
|
rm -rf src/design-system/composites/AreaChart
|
||||||
|
rm -rf src/design-system/composites/BarChart
|
||||||
|
rm src/design-system/composites/_chart-utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify lib build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds. The old components are gone, ThemedChart and Recharts re-exports are the new public API.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass. No test files existed for the deleted components.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat!: replace custom chart components with ThemedChart + Recharts
|
||||||
|
|
||||||
|
BREAKING: LineChart, AreaChart, BarChart, ChartSeries, DataPoint removed.
|
||||||
|
Use ThemedChart with Recharts children (Line, Area, Bar, etc.) instead."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update COMPONENT_GUIDE.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the charting strategy section**
|
||||||
|
|
||||||
|
Find the section starting with `## Charting Strategy` (around line 183) and replace through line 228 with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Charting Strategy
|
||||||
|
|
||||||
|
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
const data = metrics.map(m => ({ time: m.timestamp, cpu: m.value * 100 }))
|
||||||
|
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3" label="Alert" />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThemedChart Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `data` | `Record<string, any>[]` | required | Flat array of data objects |
|
||||||
|
| `height` | `number` | `200` | Chart height in pixels |
|
||||||
|
| `xDataKey` | `string` | `"time"` | Key for x-axis values |
|
||||||
|
| `xType` | `'number' \| 'category'` | `"category"` | X-axis scale type |
|
||||||
|
| `xTickFormatter` | `(value: any) => string` | — | Custom x-axis label formatter |
|
||||||
|
| `yTickFormatter` | `(value: any) => string` | — | Custom y-axis label formatter |
|
||||||
|
| `yLabel` | `string` | — | Y-axis label text |
|
||||||
|
| `children` | `ReactNode` | required | Recharts elements (Line, Area, Bar, etc.) |
|
||||||
|
| `className` | `string` | — | Container CSS class |
|
||||||
|
|
||||||
|
### Available Recharts Re-exports
|
||||||
|
|
||||||
|
`Line`, `Area`, `Bar`, `ReferenceLine`, `ReferenceArea`, `Legend`, `Brush`
|
||||||
|
|
||||||
|
For chart types not covered (treemap, radar, pie, sankey), import from `recharts` directly and use `rechartsTheme` for consistent styling.
|
||||||
|
|
||||||
|
### Theme Utilities
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `CHART_COLORS` | Array of `var(--chart-1)` through `var(--chart-8)` |
|
||||||
|
| `rechartsTheme` | Pre-configured prop objects for Recharts sub-components |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the component index table**
|
||||||
|
|
||||||
|
Find the rows for `AreaChart`, `BarChart`, `LineChart` in the component index table and replace all three with:
|
||||||
|
|
||||||
|
```
|
||||||
|
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the decision tree**
|
||||||
|
|
||||||
|
Find lines 57-60 (the chart decision tree entries):
|
||||||
|
```
|
||||||
|
- Time series → **LineChart**, **AreaChart**
|
||||||
|
- Categorical comparison → **BarChart**
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```
|
||||||
|
- Time series → **ThemedChart** with `<Line>` or `<Area>`
|
||||||
|
- Categorical comparison → **ThemedChart** with `<Bar>`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add COMPONENT_GUIDE.md
|
||||||
|
git commit -m "docs: update COMPONENT_GUIDE for ThemedChart migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Publish Design System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (version bump)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version**
|
||||||
|
|
||||||
|
In `package.json`, change `"version"` to `"0.1.47"`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit and tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore: bump version to 0.1.47"
|
||||||
|
git tag v0.1.47
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wait for CI to publish**
|
||||||
|
|
||||||
|
Wait for the Gitea CI pipeline to build and publish `@cameleer/design-system@0.1.47` to the npm registry. Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view @cameleer/design-system@0.1.47 version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Migrate Server UI AgentInstance Charts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\src\pages\AgentInstance\AgentInstance.tsx`
|
||||||
|
- Modify: `C:\Users\Hendrik\Documents\projects\cameleer3-server\ui\package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update design system dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm install @cameleer/design-system@0.1.47
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update imports in AgentInstance.tsx**
|
||||||
|
|
||||||
|
Replace the chart-related imports:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
StatCard, StatusDot, Badge,
|
||||||
|
ThemedChart, Line, Area, ReferenceLine, CHART_COLORS,
|
||||||
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, ButtonGroup, useGlobalFilters,
|
||||||
|
} from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace data prep — JVM metrics**
|
||||||
|
|
||||||
|
Replace the 4 JVM series useMemo blocks (cpuSeries, heapSeries, threadSeries, gcSeries) with flat data builders:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const formatTime = (t: string) =>
|
||||||
|
new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// JVM chart data — merge all metrics into flat objects by time bucket
|
||||||
|
const cpuData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['process.cpu.usage.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, cpu: p.value * 100 }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const heapData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.memory.used.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, heap: p.value / (1024 * 1024) }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const threadData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.threads.live.value'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, threads: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
|
const gcData = useMemo(() => {
|
||||||
|
const pts = jvmMetrics?.metrics?.['jvm.gc.pause.total_time'];
|
||||||
|
if (!pts?.length) return [];
|
||||||
|
return pts.map((p: any) => ({ time: p.time, gc: p.value }));
|
||||||
|
}, [jvmMetrics]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace data prep — throughput and error**
|
||||||
|
|
||||||
|
Replace the throughputSeries and errorSeries useMemo blocks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const throughputData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), throughput: d.throughput }));
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
const errorData = useMemo(() => {
|
||||||
|
if (!chartData.length) return [];
|
||||||
|
return chartData.map((d: any) => ({ time: d.date.toISOString(), errorPct: d.errorPct }));
|
||||||
|
}, [chartData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace chart JSX — CPU Usage**
|
||||||
|
|
||||||
|
Replace the CPU chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{cpuData.length ? (
|
||||||
|
<ThemedChart data={cpuData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Area dataKey="cpu" name="CPU %" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
<ReferenceLine y={85} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Alert', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No CPU metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace chart JSX — Memory (Heap)**
|
||||||
|
|
||||||
|
Replace the heap chart card content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{heapData.length ? (
|
||||||
|
<ThemedChart data={heapData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="MB">
|
||||||
|
<Area dataKey="heap" name="Heap MB" stroke={CHART_COLORS[0]}
|
||||||
|
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
{heapMax != null && (
|
||||||
|
<ReferenceLine y={heapMax / (1024 * 1024)} stroke="var(--error)" strokeDasharray="5 3"
|
||||||
|
label={{ value: 'Max Heap', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
|
||||||
|
)}
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No heap metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Replace chart JSX — Throughput**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{throughputData.length ? (
|
||||||
|
<ThemedChart data={throughputData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="msg/s">
|
||||||
|
<Line dataKey="throughput" name="msg/s" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No throughput data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Replace chart JSX — Error Rate**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{errorData.length ? (
|
||||||
|
<ThemedChart data={errorData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="%">
|
||||||
|
<Line dataKey="errorPct" name="Error %" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No error data in range" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Replace chart JSX — Thread Count**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{threadData.length ? (
|
||||||
|
<ThemedChart data={threadData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="threads">
|
||||||
|
<Line dataKey="threads" name="Threads" stroke={CHART_COLORS[0]}
|
||||||
|
strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No thread metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Replace chart JSX — GC Pauses**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{gcData.length ? (
|
||||||
|
<ThemedChart data={gcData} height={160} xDataKey="time"
|
||||||
|
xTickFormatter={formatTime} yLabel="ms">
|
||||||
|
<Area dataKey="gc" name="GC ms" stroke={CHART_COLORS[1]}
|
||||||
|
fill={CHART_COLORS[1]} fillOpacity={0.1} strokeWidth={2} dot={false} />
|
||||||
|
</ThemedChart>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No GC metrics available" />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: Update the Thread Count meta display**
|
||||||
|
|
||||||
|
The thread count meta currently reads from `threadSeries`. Update to read from `threadData`:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```tsx
|
||||||
|
{threadSeries
|
||||||
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
{threadData.length
|
||||||
|
? `${threadData[threadData.length - 1].threads.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 12: Build server UI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server\ui
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 13: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\Hendrik\Documents\projects\cameleer3-server
|
||||||
|
git add ui/
|
||||||
|
git commit -m "feat: migrate agent charts to ThemedChart + Recharts
|
||||||
|
|
||||||
|
Replace custom LineChart/AreaChart/BarChart usage with ThemedChart
|
||||||
|
wrapper. Data format changed from ChartSeries[] to Recharts-native
|
||||||
|
flat objects. Uses DS v0.1.47."
|
||||||
|
git push
|
||||||
|
```
|
||||||
121
docs/superpowers/specs/2026-04-12-recharts-migration-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Recharts Migration Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the design system's hand-rolled SVG chart components (LineChart, AreaChart, BarChart) with Recharts-based implementations. The current custom charts have responsiveness issues (preserveAspectRatio distorting text), limited tooltip/axis formatting, and growing maintenance burden.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add Recharts as a DS dependency. Replace the three custom chart components and `_chart-utils.ts` with a single `ThemedChart` wrapper component. Consumers compose Recharts elements (`<Line>`, `<Area>`, `<Bar>`, etc.) as children inside `<ThemedChart>`. The DS re-exports Recharts components so consumers don't need to add Recharts as a separate dependency.
|
||||||
|
|
||||||
|
Sparkline stays hand-rolled SVG (no axes, no tooltips — Recharts is overkill).
|
||||||
|
|
||||||
|
### What gets removed
|
||||||
|
|
||||||
|
- `composites/LineChart/` directory (LineChart.tsx, LineChart.module.css)
|
||||||
|
- `composites/AreaChart/` directory (AreaChart.tsx, AreaChart.module.css)
|
||||||
|
- `composites/BarChart/` directory (BarChart.tsx, BarChart.module.css)
|
||||||
|
- `composites/_chart-utils.ts`
|
||||||
|
- `ChartSeries` and `DataPoint` type exports
|
||||||
|
|
||||||
|
### What gets added
|
||||||
|
|
||||||
|
- `composites/ThemedChart/ThemedChart.tsx` — wrapper component
|
||||||
|
- `composites/ThemedChart/ChartTooltip.tsx` — internal themed tooltip
|
||||||
|
- `composites/ThemedChart/ThemedChart.module.css`
|
||||||
|
- Recharts re-exports from the DS barrel
|
||||||
|
|
||||||
|
### What stays unchanged
|
||||||
|
|
||||||
|
- `utils/rechartsTheme.ts` (now also used internally by ThemedChart)
|
||||||
|
- `CHART_COLORS`
|
||||||
|
- `primitives/Sparkline/`
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
Consumers use Recharts-native flat data format instead of the current `ChartSeries[]`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (custom)
|
||||||
|
const series = [
|
||||||
|
{ label: 'CPU %', data: pts.map(p => ({ x: new Date(p.time), y: p.value * 100 })) }
|
||||||
|
]
|
||||||
|
<AreaChart series={series} height={160} yLabel="%" />
|
||||||
|
|
||||||
|
// After (Recharts-native)
|
||||||
|
const data = pts.map(p => ({ time: p.time, cpu: p.value * 100 }))
|
||||||
|
<ThemedChart data={data} height={160} xDataKey="time" yLabel="%">
|
||||||
|
<Area dataKey="cpu" stroke={CHART_COLORS[0]} fill={CHART_COLORS[0]} fillOpacity={0.1} />
|
||||||
|
</ThemedChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a breaking change. The migration cost is bounded — only `AgentInstance.tsx` in the server repo uses these components.
|
||||||
|
|
||||||
|
## ThemedChart API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number // default 200
|
||||||
|
xDataKey?: string // default "time"
|
||||||
|
xType?: 'number' | 'category' // default "category"
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode // Recharts elements
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ThemedChart renders internally:
|
||||||
|
- `ResponsiveContainer` (width 100%, height from prop)
|
||||||
|
- `ComposedChart` (supports mixing Line + Bar + Area in one chart)
|
||||||
|
- `CartesianGrid` with `rechartsTheme.cartesianGrid`
|
||||||
|
- `XAxis` with `rechartsTheme.xAxis` + formatter
|
||||||
|
- `YAxis` with `rechartsTheme.yAxis` + formatter + label
|
||||||
|
- `Tooltip` with custom `ChartTooltip` component
|
||||||
|
|
||||||
|
## Tooltip
|
||||||
|
|
||||||
|
ThemedChart provides a custom `ChartTooltip` component (internal, not exported) that:
|
||||||
|
- Shows the x-value formatted as date/time in a header row (mono font, subtle border separator)
|
||||||
|
- Shows each series with colored dot + label + formatted value
|
||||||
|
- Uses DS tokens for styling (surface bg, border, shadow, mono font)
|
||||||
|
|
||||||
|
Consumers can override by passing their own `<Tooltip content={...} />` as a child. Recharts uses the last Tooltip it finds, so a consumer-provided one replaces the default.
|
||||||
|
|
||||||
|
## DS Re-exports
|
||||||
|
|
||||||
|
Selected Recharts components re-exported so consumers don't need `recharts` in their own package.json:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar, ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush, ComposedChart,
|
||||||
|
} from 'recharts'
|
||||||
|
```
|
||||||
|
|
||||||
|
More can be added on demand.
|
||||||
|
|
||||||
|
## Consumer Migration — Server UI
|
||||||
|
|
||||||
|
`AgentInstance.tsx` has 6 charts to migrate:
|
||||||
|
|
||||||
|
| Chart | Before | After |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| CPU Usage | `<AreaChart series={cpuSeries} threshold={...}>` | `<ThemedChart><Area dataKey="cpu" /><ReferenceLine y={85} /></ThemedChart>` |
|
||||||
|
| Memory (Heap) | `<AreaChart series={heapSeries} threshold={...}>` | `<ThemedChart><Area dataKey="heap" /><ReferenceLine y={max} /></ThemedChart>` |
|
||||||
|
| Throughput | `<LineChart series={throughputSeries}>` | `<ThemedChart><Line dataKey="throughput" /></ThemedChart>` |
|
||||||
|
| Error Rate | `<LineChart series={errorSeries}>` | `<ThemedChart><Line dataKey="errorPct" /></ThemedChart>` |
|
||||||
|
| Thread Count | `<LineChart series={threadSeries}>` | `<ThemedChart><Line dataKey="threads" /></ThemedChart>` |
|
||||||
|
| GC Pauses | `<AreaChart series={gcSeries}>` | `<ThemedChart><Area dataKey="gc" /></ThemedChart>` |
|
||||||
|
|
||||||
|
Data prep changes from building `ChartSeries[]` arrays to flat objects with named keys.
|
||||||
|
|
||||||
|
## Not in Scope
|
||||||
|
|
||||||
|
- Sparkline migration (no responsiveness issues, no axes/tooltips)
|
||||||
|
- SaaS UI (no chart component usage)
|
||||||
|
- Dashboard tab (already uses Recharts directly with `rechartsTheme`)
|
||||||
|
- New chart types (treemap, radar, etc. — consumers compose these directly)
|
||||||
411
package-lock.json
generated
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.46",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
@@ -1012,6 +1013,42 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1519,6 +1556,18 @@
|
|||||||
"string-argv": "~0.3.1"
|
"string-argv": "~0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -1680,6 +1729,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -1708,7 +1820,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1724,6 +1836,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/whatwg-mimetype": {
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
@@ -2319,6 +2437,15 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2399,9 +2526,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/de-indent": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
@@ -2427,6 +2675,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -2506,6 +2760,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -2568,6 +2832,12 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -2814,6 +3084,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-lazy": {
|
"node_modules/import-lazy": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
||||||
@@ -2834,6 +3114,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
@@ -3476,10 +3765,32 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -3528,6 +3839,36 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -3542,6 +3883,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -3552,6 +3908,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -3921,6 +4283,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -4051,6 +4419,37 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|||||||
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.48",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
@@ -11,14 +11,7 @@
|
|||||||
"import": "./dist/index.es.js"
|
"import": "./dist/index.es.js"
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css",
|
"./style.css": "./dist/style.css",
|
||||||
"./logo": "./assets/cameleer3-logo.png",
|
"./assets/*": "./assets/*"
|
||||||
"./logo-16": "./assets/cameleer3-16.png",
|
|
||||||
"./logo-32": "./assets/cameleer3-32.png",
|
|
||||||
"./logo-48": "./assets/cameleer3-48.png",
|
|
||||||
"./logo-180": "./assets/cameleer3-180.png",
|
|
||||||
"./logo-192": "./assets/cameleer3-192.png",
|
|
||||||
"./logo-512": "./assets/cameleer3-512.png",
|
|
||||||
"./logo-svg": "./assets/camel-logo.svg"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -47,7 +40,8 @@
|
|||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
112
src/assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,157 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Threshold line */
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Area + line */
|
|
||||||
.area {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Crosshair */
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip */
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legend */
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Axis labels */
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './AreaChart.module.css'
|
|
||||||
import {
|
|
||||||
computeYScale,
|
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
seriesPath,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
|
||||||
value: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AreaChartProps {
|
|
||||||
series: ChartSeries[]
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const DIMS = {
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingRight: 16,
|
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AreaChart({
|
|
||||||
series,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
threshold,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
className,
|
|
||||||
}: AreaChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
|
||||||
const allData = series.flatMap((s) => s.data)
|
|
||||||
|
|
||||||
if (allData.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
|
||||||
const { toX } = computeXScale(series, dims)
|
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
// Y-axis ticks
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
|
|
||||||
// X-axis ticks (first, middle, last)
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
|
|
||||||
// Find closest x value
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Area chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* SLA threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Area fills */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const areaD = seriesPath(s, toX, toY, bottomY)
|
|
||||||
return (
|
|
||||||
<path
|
|
||||||
key={`area-${i}`}
|
|
||||||
d={areaD}
|
|
||||||
fill={color}
|
|
||||||
className={styles.area}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Lines */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
className={styles.line}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Crosshair */}
|
|
||||||
{tooltip && (
|
|
||||||
<line
|
|
||||||
x1={tooltip.x}
|
|
||||||
y1={dims.paddingTop}
|
|
||||||
x2={tooltip.x}
|
|
||||||
y2={bottomY}
|
|
||||||
className={styles.crosshair}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{
|
|
||||||
left: tooltip.x + 12,
|
|
||||||
top: tooltip.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
background-color: var(--bg-inset);
|
background-color: var(--bg-inset);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipTitle {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './BarChart.module.css'
|
|
||||||
import { formatAxisLabel, CHART_COLORS } from '../_chart-utils'
|
|
||||||
|
|
||||||
interface BarSeries {
|
|
||||||
label: string
|
|
||||||
data: { x: string; y: number }[]
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BarChartProps {
|
|
||||||
series: BarSeries[]
|
|
||||||
stacked?: boolean
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PADDING = { top: 12, right: 16, bottom: 40, left: 48 }
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const BAR_GAP = 0.2 // fraction of bar group width reserved for gaps
|
|
||||||
|
|
||||||
export function BarChart({
|
|
||||||
series,
|
|
||||||
stacked = false,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
className,
|
|
||||||
}: BarChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
label: string
|
|
||||||
values: { series: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
if (series.length === 0 || series[0].data.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all x categories (union across all series)
|
|
||||||
const categories = Array.from(new Set(series.flatMap((s) => s.data.map((d) => d.x))))
|
|
||||||
const numCats = categories.length
|
|
||||||
|
|
||||||
const plotW = width - PADDING.left - PADDING.right
|
|
||||||
const plotH = height - PADDING.top - PADDING.bottom
|
|
||||||
|
|
||||||
// Compute max Y
|
|
||||||
let maxY = 0
|
|
||||||
if (stacked) {
|
|
||||||
for (const cat of categories) {
|
|
||||||
const sum = series.reduce((acc, s) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
return acc + (pt?.y ?? 0)
|
|
||||||
}, 0)
|
|
||||||
maxY = Math.max(maxY, sum)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
maxY = Math.max(...series.flatMap((s) => s.data.map((d) => d.y)))
|
|
||||||
}
|
|
||||||
maxY = maxY || 1
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((maxY / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
const toY = (val: number) => PADDING.top + plotH - (val / maxY) * plotH
|
|
||||||
const bottomY = PADDING.top + plotH
|
|
||||||
|
|
||||||
const catWidth = plotW / numCats
|
|
||||||
const groupGap = catWidth * BAR_GAP
|
|
||||||
const groupW = catWidth - groupGap
|
|
||||||
|
|
||||||
function handleMouseEnter(
|
|
||||||
catLabel: string,
|
|
||||||
mx: number,
|
|
||||||
my: number,
|
|
||||||
values: { series: string; value: number; color: string }[],
|
|
||||||
) {
|
|
||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
|
|
||||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - rect.left,
|
|
||||||
e.clientY - rect.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Bar chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={PADDING.left}
|
|
||||||
y1={y}
|
|
||||||
x2={width - PADDING.right}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={PADDING.left - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bars */}
|
|
||||||
{categories.map((cat, ci) => {
|
|
||||||
const groupX = PADDING.left + ci * catWidth + groupGap / 2
|
|
||||||
|
|
||||||
if (stacked) {
|
|
||||||
let stackY = bottomY
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
const y = stackY - barH
|
|
||||||
stackY -= barH
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX}
|
|
||||||
y={y}
|
|
||||||
width={groupW}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouped
|
|
||||||
const barW = groupW / series.length
|
|
||||||
return (
|
|
||||||
<g key={cat}>
|
|
||||||
{series.map((s, si) => {
|
|
||||||
const pt = s.data.find((d) => d.x === cat)
|
|
||||||
const val = pt?.y ?? 0
|
|
||||||
const barH = (val / maxY) * plotH
|
|
||||||
const color = s.color ?? CHART_COLORS[si % CHART_COLORS.length]
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={si}
|
|
||||||
x={groupX + si * barW}
|
|
||||||
y={toY(val)}
|
|
||||||
width={barW - 1}
|
|
||||||
height={barH}
|
|
||||||
fill={color}
|
|
||||||
className={styles.bar}
|
|
||||||
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={bottomY + 14}
|
|
||||||
className={styles.catLabel}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
<div className={styles.tooltipTitle}>{tooltip.label}</div>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.series} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.series}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -239,13 +239,13 @@
|
|||||||
|
|
||||||
.itemTime {
|
.itemTime {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMeta {
|
.itemMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
|
|
||||||
/* Match context snippet */
|
/* Match context snippet */
|
||||||
.matchContext {
|
.matchContext {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -316,6 +316,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
.th {
|
.th {
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
.rangeInfo {
|
.rangeInfo {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationRight {
|
.paginationRight {
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pageSizeLabel {
|
.pageSizeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
|
|
||||||
.pageNum {
|
.pageNum {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
border: 1px solid var(--amber-light);
|
border: 1px solid var(--amber-light);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
color: var(--amber-deep);
|
color: var(--amber-deep);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clearAll {
|
.clearAll {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
.trend {
|
.trend {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
.trendMuted { color: var(--text-muted); }
|
.trendMuted { color: var(--text-muted); }
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg {
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gridLine {
|
|
||||||
stroke: var(--border-subtle);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.axisLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLine {
|
|
||||||
stroke: var(--error);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thresholdLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
fill: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair {
|
|
||||||
stroke: var(--text-faint);
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 3 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipRow:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipLabel {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendDot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legendLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
text-align: center;
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xLabel {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-left: 48px;
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import styles from './LineChart.module.css'
|
|
||||||
import {
|
|
||||||
computeYScale,
|
|
||||||
computeXScale,
|
|
||||||
seriesPoints,
|
|
||||||
formatAxisLabel,
|
|
||||||
CHART_COLORS,
|
|
||||||
type ChartSeries,
|
|
||||||
} from '../_chart-utils'
|
|
||||||
|
|
||||||
interface Threshold {
|
|
||||||
value: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LineChartProps {
|
|
||||||
series: ChartSeries[]
|
|
||||||
xLabel?: string
|
|
||||||
yLabel?: string
|
|
||||||
threshold?: Threshold
|
|
||||||
height?: number
|
|
||||||
width?: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Y_TICK_COUNT = 4
|
|
||||||
const DIMS = {
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingRight: 16,
|
|
||||||
paddingBottom: 28,
|
|
||||||
paddingLeft: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LineChart({
|
|
||||||
series,
|
|
||||||
xLabel,
|
|
||||||
yLabel,
|
|
||||||
threshold,
|
|
||||||
height = 200,
|
|
||||||
width = 400,
|
|
||||||
className,
|
|
||||||
}: LineChartProps) {
|
|
||||||
const [tooltip, setTooltip] = useState<{
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
values: { label: string; value: number; color: string }[]
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const dims = { ...DIMS, width, height }
|
|
||||||
const allData = series.flatMap((s) => s.data)
|
|
||||||
|
|
||||||
if (allData.length === 0) {
|
|
||||||
return <div className={`${styles.empty} ${className ?? ''}`}>No data</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const { max, toY } = computeYScale(series, dims, threshold?.value)
|
|
||||||
const { toX } = computeXScale(series, dims)
|
|
||||||
const plotH = height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const plotW = width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const bottomY = dims.paddingTop + plotH
|
|
||||||
|
|
||||||
const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) =>
|
|
||||||
Math.round((max / Y_TICK_COUNT) * i),
|
|
||||||
)
|
|
||||||
|
|
||||||
const firstSeries = series[0]
|
|
||||||
const xSamples =
|
|
||||||
firstSeries && firstSeries.data.length > 0
|
|
||||||
? [
|
|
||||||
firstSeries.data[0].x,
|
|
||||||
firstSeries.data[Math.floor(firstSeries.data.length / 2)]?.x,
|
|
||||||
firstSeries.data[firstSeries.data.length - 1].x,
|
|
||||||
].filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const mx = e.clientX - rect.left
|
|
||||||
const my = e.clientY - rect.top
|
|
||||||
const pctX = (mx - dims.paddingLeft) / plotW
|
|
||||||
|
|
||||||
const values = series.map((s, i) => {
|
|
||||||
const idx = Math.round(pctX * (s.data.length - 1))
|
|
||||||
const clamped = Math.max(0, Math.min(s.data.length - 1, idx))
|
|
||||||
const pt = s.data[clamped]
|
|
||||||
return {
|
|
||||||
label: s.label,
|
|
||||||
value: pt?.y ?? 0,
|
|
||||||
color: s.color ?? CHART_COLORS[i % CHART_COLORS.length],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTooltip({ x: mx, y: my, values })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={styles.svg}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={() => setTooltip(null)}
|
|
||||||
aria-label="Line chart"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Grid lines */}
|
|
||||||
{yTicks.map((val) => {
|
|
||||||
const y = toY(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={y}
|
|
||||||
className={styles.gridLine}
|
|
||||||
/>
|
|
||||||
<text x={dims.paddingLeft - 6} y={y + 4} className={styles.axisLabel} textAnchor="end">
|
|
||||||
{formatAxisLabel(val)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
{xSamples.map((xVal, i) => {
|
|
||||||
const xPos = toX(xVal)
|
|
||||||
const xv = xVal instanceof Date ? xVal : new Date(xVal as number)
|
|
||||||
const label =
|
|
||||||
xVal instanceof Date || (typeof xVal === 'number' && xVal > 1e10)
|
|
||||||
? xv.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: formatAxisLabel(xVal as number)
|
|
||||||
const anchor = i === 0 ? 'start' : i === xSamples.length - 1 ? 'end' : 'middle'
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xPos}
|
|
||||||
y={height - dims.paddingBottom + 16}
|
|
||||||
className={styles.axisLabel}
|
|
||||||
textAnchor={anchor}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Threshold line */}
|
|
||||||
{threshold && (
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1={dims.paddingLeft}
|
|
||||||
y1={toY(threshold.value)}
|
|
||||||
x2={width - dims.paddingRight}
|
|
||||||
y2={toY(threshold.value)}
|
|
||||||
className={styles.thresholdLine}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={width - dims.paddingRight - 4}
|
|
||||||
y={toY(threshold.value) - 4}
|
|
||||||
className={styles.thresholdLabel}
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{threshold.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lines only (no area fill) */}
|
|
||||||
{series.map((s, i) => {
|
|
||||||
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
|
|
||||||
const pts = seriesPoints(s, toX, toY)
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={`line-${i}`}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
className={styles.line}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Crosshair */}
|
|
||||||
{tooltip && (
|
|
||||||
<line
|
|
||||||
x1={tooltip.x}
|
|
||||||
y1={dims.paddingTop}
|
|
||||||
x2={tooltip.x}
|
|
||||||
y2={bottomY}
|
|
||||||
className={styles.crosshair}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
|
||||||
<div
|
|
||||||
className={styles.tooltip}
|
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y }}
|
|
||||||
>
|
|
||||||
{tooltip.values.map((v) => (
|
|
||||||
<div key={v.label} className={styles.tooltipRow}>
|
|
||||||
<span className={styles.tooltipDot} style={{ background: v.color }} />
|
|
||||||
<span className={styles.tooltipLabel}>{v.label}:</span>
|
|
||||||
<span className={styles.tooltipValue}>{formatAxisLabel(v.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
{series.length > 1 && (
|
|
||||||
<div className={styles.legend}>
|
|
||||||
{series.map((s, i) => (
|
|
||||||
<div key={s.label} className={styles.legendItem}>
|
|
||||||
<span
|
|
||||||
className={styles.legendDot}
|
|
||||||
style={{ background: s.color ?? CHART_COLORS[i % CHART_COLORS.length] }}
|
|
||||||
/>
|
|
||||||
<span className={styles.legendLabel}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{xLabel && <div className={styles.xLabel}>{xLabel}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
.dividerText {
|
.dividerText {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.forgotLink {
|
.forgotLink {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.clickable {
|
.row.clickable {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
.dur {
|
.dur {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
import { Dropdown } from '../Dropdown/Dropdown'
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
import { formatDuration } from '../../../utils/format-utils'
|
||||||
|
|
||||||
export interface NodeBadge {
|
export interface NodeBadge {
|
||||||
label: string
|
label: string
|
||||||
@@ -42,12 +43,6 @@ interface RouteFlowProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function durationClass(ms: number, status: string): string {
|
function durationClass(ms: number, status: string): string {
|
||||||
if (status === 'fail') return styles.durBreach
|
if (status === 'fail') return styles.durBreach
|
||||||
if (ms < 50) return styles.durFast
|
if (ms < 50) return styles.durFast
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.tooltip {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
41
src/design-system/composites/ThemedChart/ChartTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { TooltipProps } from 'recharts'
|
||||||
|
import styles from './ChartTooltip.module.css'
|
||||||
|
|
||||||
|
function formatValue(val: number): string {
|
||||||
|
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||||
|
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
||||||
|
if (Number.isInteger(val)) return String(val)
|
||||||
|
return val.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(val: unknown): string | null {
|
||||||
|
if (val == null) return null
|
||||||
|
const str = String(val)
|
||||||
|
const ms = typeof val === 'number' && val > 1e12 ? val
|
||||||
|
: typeof val === 'number' && val > 1e9 ? val * 1000
|
||||||
|
: Date.parse(str)
|
||||||
|
if (isNaN(ms)) return str
|
||||||
|
return new Date(ms).toLocaleString([], {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
const timeLabel = formatTimestamp(label)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
{timeLabel && <div className={styles.time}>{timeLabel}</div>}
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.dataKey as string} className={styles.row}>
|
||||||
|
<span className={styles.dot} style={{ background: entry.color }} />
|
||||||
|
<span className={styles.label}>{entry.name}:</span>
|
||||||
|
<span className={styles.value}>{formatValue(entry.value as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ThemedChart } from './ThemedChart'
|
||||||
|
import { Line } from 'recharts'
|
||||||
|
|
||||||
|
// Recharts uses ResizeObserver internally
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverMock as any
|
||||||
|
|
||||||
|
describe('ThemedChart', () => {
|
||||||
|
it('renders nothing when data is empty', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={[]}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a chart container when data is provided', () => {
|
||||||
|
const data = [
|
||||||
|
{ time: '10:00', value: 10 },
|
||||||
|
{ time: '10:01', value: 20 },
|
||||||
|
]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} height={160}>
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.recharts-responsive-container')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const data = [{ time: '10:00', value: 5 }]
|
||||||
|
const { container } = render(
|
||||||
|
<ThemedChart data={data} className="my-chart">
|
||||||
|
<Line dataKey="value" />
|
||||||
|
</ThemedChart>,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('.my-chart')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
src/design-system/composites/ThemedChart/ThemedChart.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
ComposedChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { rechartsTheme } from '../../utils/rechartsTheme'
|
||||||
|
import { ChartTooltip } from './ChartTooltip'
|
||||||
|
|
||||||
|
interface ThemedChartProps {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
height?: number
|
||||||
|
xDataKey?: string
|
||||||
|
xType?: 'number' | 'category'
|
||||||
|
xTickFormatter?: (value: any) => string
|
||||||
|
yTickFormatter?: (value: any) => string
|
||||||
|
yLabel?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
xDataKey = 'time',
|
||||||
|
xType = 'category',
|
||||||
|
xTickFormatter,
|
||||||
|
yTickFormatter,
|
||||||
|
yLabel,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ThemedChartProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show ~5-6 ticks max to avoid label overlap
|
||||||
|
const maxTicks = 6
|
||||||
|
const tickInterval = data.length > maxTicks
|
||||||
|
? Math.ceil(data.length / maxTicks) - 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid {...rechartsTheme.cartesianGrid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xDataKey}
|
||||||
|
type={xType}
|
||||||
|
{...rechartsTheme.xAxis}
|
||||||
|
tickFormatter={xTickFormatter}
|
||||||
|
interval={tickInterval}
|
||||||
|
minTickGap={40}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...rechartsTheme.yAxis}
|
||||||
|
tickFormatter={yTickFormatter}
|
||||||
|
label={yLabel ? {
|
||||||
|
value: yLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'var(--text-muted)' },
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={rechartsTheme.tooltip.cursor} />
|
||||||
|
{children}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,8 +81,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const toast = useCallback(
|
const toast = useCallback(
|
||||||
(options: ToastOptions): string => {
|
(options: ToastOptions): string => {
|
||||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
const duration = options.duration ?? DEFAULT_DURATION
|
|
||||||
const variant = options.variant ?? 'info'
|
const variant = options.variant ?? 'info'
|
||||||
|
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
|
||||||
|
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
|
||||||
|
|
||||||
const newToast: ToastItem = {
|
const newToast: ToastItem = {
|
||||||
id,
|
id,
|
||||||
@@ -99,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
return next.slice(-MAX_TOASTS)
|
return next.slice(-MAX_TOASTS)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Schedule auto-dismiss
|
// Schedule auto-dismiss (duration 0 = persist until manual dismiss)
|
||||||
const timer = setTimeout(() => {
|
if (duration > 0) {
|
||||||
dismiss(id)
|
const timer = setTimeout(() => {
|
||||||
}, duration)
|
dismiss(id)
|
||||||
timersRef.current.set(id, timer)
|
}, duration)
|
||||||
|
timersRef.current.set(id, timer)
|
||||||
|
}
|
||||||
|
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
export interface DataPoint {
|
|
||||||
x: number | Date
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartSeries {
|
|
||||||
label: string
|
|
||||||
data: DataPoint[]
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartDimensions {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
paddingTop: number
|
|
||||||
paddingRight: number
|
|
||||||
paddingBottom: number
|
|
||||||
paddingLeft: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeYScale(
|
|
||||||
series: ChartSeries[],
|
|
||||||
dims: ChartDimensions,
|
|
||||||
threshold?: number,
|
|
||||||
) {
|
|
||||||
const allY = series.flatMap((s) => s.data.map((d) => d.y))
|
|
||||||
if (threshold != null) allY.push(threshold)
|
|
||||||
const min = 0
|
|
||||||
const max = Math.max(...allY, 1)
|
|
||||||
const range = max - min
|
|
||||||
|
|
||||||
const plotH = dims.height - dims.paddingTop - dims.paddingBottom
|
|
||||||
const toY = (val: number) => dims.paddingTop + plotH - ((val - min) / range) * plotH
|
|
||||||
|
|
||||||
return { min, max, range, toY }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeXScale(series: ChartSeries[], dims: ChartDimensions) {
|
|
||||||
const allX = series.flatMap((s) =>
|
|
||||||
s.data.map((d) => (d.x instanceof Date ? d.x.getTime() : d.x)),
|
|
||||||
)
|
|
||||||
const minX = Math.min(...allX)
|
|
||||||
const maxX = Math.max(...allX)
|
|
||||||
const rangeX = maxX - minX || 1
|
|
||||||
|
|
||||||
const plotW = dims.width - dims.paddingLeft - dims.paddingRight
|
|
||||||
const toX = (val: number | Date) => {
|
|
||||||
const v = val instanceof Date ? val.getTime() : val
|
|
||||||
return dims.paddingLeft + ((v - minX) / rangeX) * plotW
|
|
||||||
}
|
|
||||||
|
|
||||||
return { minX, maxX, rangeX, toX }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seriesPoints(
|
|
||||||
series: ChartSeries,
|
|
||||||
toX: (v: number | Date) => number,
|
|
||||||
toY: (v: number) => number,
|
|
||||||
): string {
|
|
||||||
return series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seriesPath(
|
|
||||||
series: ChartSeries,
|
|
||||||
toX: (v: number | Date) => number,
|
|
||||||
toY: (v: number) => number,
|
|
||||||
bottomY: number,
|
|
||||||
): string {
|
|
||||||
if (series.data.length === 0) return ''
|
|
||||||
const pts = series.data.map((d) => `${toX(d.x).toFixed(1)},${toY(d.y).toFixed(1)}`)
|
|
||||||
const firstX = toX(series.data[0].x).toFixed(1)
|
|
||||||
const lastX = toX(series.data[series.data.length - 1].x).toFixed(1)
|
|
||||||
return `M${pts.join(' L')} L${lastX},${bottomY} L${firstX},${bottomY} Z`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAxisLabel(val: number): string {
|
|
||||||
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
|
||||||
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`
|
|
||||||
if (Number.isInteger(val)) return String(val)
|
|
||||||
return val.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatXLabel(val: number | Date, _totalPoints: number): string {
|
|
||||||
if (val instanceof Date || (typeof val === 'number' && val > 1e10)) {
|
|
||||||
const d = val instanceof Date ? val : new Date(val)
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
return formatAxisLabel(val as number)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default color tokens for series (fallback when no color given)
|
|
||||||
export const CHART_COLORS = [
|
|
||||||
'var(--chart-1)',
|
|
||||||
'var(--chart-2)',
|
|
||||||
'var(--chart-3)',
|
|
||||||
'var(--chart-4)',
|
|
||||||
'var(--chart-5)',
|
|
||||||
'var(--chart-6)',
|
|
||||||
'var(--chart-7)',
|
|
||||||
'var(--chart-8)',
|
|
||||||
]
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
export { Accordion } from './Accordion/Accordion'
|
export { Accordion } from './Accordion/Accordion'
|
||||||
export { AlertDialog } from './AlertDialog/AlertDialog'
|
export { AlertDialog } from './AlertDialog/AlertDialog'
|
||||||
export { AreaChart } from './AreaChart/AreaChart'
|
|
||||||
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
|
export { AvatarGroup } from './AvatarGroup/AvatarGroup'
|
||||||
export { BarChart } from './BarChart/BarChart'
|
|
||||||
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
export { Breadcrumb } from './Breadcrumb/Breadcrumb'
|
||||||
export { CommandPalette } from './CommandPalette/CommandPalette'
|
export { CommandPalette } from './CommandPalette/CommandPalette'
|
||||||
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
export type { SearchResult, SearchCategory, ScopeFilter } from './CommandPalette/types'
|
||||||
@@ -13,13 +11,13 @@ export type { Column, DataTableProps } from './DataTable/types'
|
|||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
export { EntityList } from './EntityList/EntityList'
|
export { EntityList } from './EntityList/EntityList'
|
||||||
export { Dropdown } from './Dropdown/Dropdown'
|
export { Dropdown } from './Dropdown/Dropdown'
|
||||||
|
export type { DropdownItem } from './Dropdown/Dropdown'
|
||||||
export { EventFeed } from './EventFeed/EventFeed'
|
export { EventFeed } from './EventFeed/EventFeed'
|
||||||
export { GroupCard } from './GroupCard/GroupCard'
|
export { GroupCard } from './GroupCard/GroupCard'
|
||||||
export { KpiStrip } from './KpiStrip/KpiStrip'
|
export { KpiStrip } from './KpiStrip/KpiStrip'
|
||||||
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
|
||||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||||
export { FilterBar } from './FilterBar/FilterBar'
|
export { FilterBar } from './FilterBar/FilterBar'
|
||||||
export { LineChart } from './LineChart/LineChart'
|
|
||||||
export { LogViewer } from './LogViewer/LogViewer'
|
export { LogViewer } from './LogViewer/LogViewer'
|
||||||
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
||||||
export { LoginDialog } from './LoginForm/LoginDialog'
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
@@ -42,6 +40,11 @@ export { Tabs } from './Tabs/Tabs'
|
|||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|
||||||
// Chart utilities for consumers using Recharts or custom charts
|
// Charts — ThemedChart wrapper + Recharts re-exports
|
||||||
export { CHART_COLORS } from './_chart-utils'
|
export { ThemedChart } from './ThemedChart/ThemedChart'
|
||||||
export type { ChartSeries, DataPoint } from './_chart-utils'
|
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
|
||||||
|
export {
|
||||||
|
Line, Area, Bar,
|
||||||
|
ReferenceLine, ReferenceArea,
|
||||||
|
Legend, Brush,
|
||||||
|
} from 'recharts'
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvide
|
|||||||
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||||
export * from './utils/hashColor'
|
export * from './utils/hashColor'
|
||||||
export * from './utils/timePresets'
|
export * from './utils/timePresets'
|
||||||
export * from './utils/rechartsTheme'
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
margin-left: 4px;
|
margin-left: 8px;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
@@ -238,7 +239,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionLabel {
|
.treeSectionLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
@@ -313,7 +314,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
/* Badge */
|
/* Badge */
|
||||||
.treeBadge {
|
.treeBadge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--sidebar-muted);
|
color: var(--sidebar-muted);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|||||||
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import styles from './TopBar.module.css'
|
||||||
|
|
||||||
|
interface AutoRefreshToggleProps {
|
||||||
|
active: boolean
|
||||||
|
onChange: (active: boolean) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoRefreshToggle({ active, onChange, className }: AutoRefreshToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.liveToggle} ${active ? styles.liveToggleActive : ''} ${className ?? ''}`}
|
||||||
|
onClick={() => onChange(!active)}
|
||||||
|
type="button"
|
||||||
|
aria-label={active ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||||
|
title={active ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
|
>
|
||||||
|
<span className={styles.liveDot} />
|
||||||
|
{active ? 'AUTO' : 'MANUAL'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import styles from './TopBar.module.css'
|
||||||
|
|
||||||
|
interface SearchTriggerProps {
|
||||||
|
onClick: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.search} ${className ?? ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
aria-label="Open search"
|
||||||
|
>
|
||||||
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Search size={13} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
.kbd {
|
.kbd {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -155,17 +155,6 @@
|
|||||||
.env {
|
.env {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 30px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
import { Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
||||||
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
|
||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
|
||||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
|
||||||
import { useTheme } from '../../providers/ThemeProvider'
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
@@ -17,26 +12,21 @@ interface TopBarProps {
|
|||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: ReactNode
|
environment?: ReactNode
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
|
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ITEMS: ButtonGroupItem[] = [
|
|
||||||
{ value: 'completed', label: 'OK', color: 'var(--success)' },
|
|
||||||
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
|
|
||||||
{ value: 'failed', label: 'Error', color: 'var(--error)' },
|
|
||||||
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
environment,
|
environment,
|
||||||
user,
|
user,
|
||||||
|
userMenuItems,
|
||||||
onLogout,
|
onLogout,
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const globalFilters = useGlobalFilters()
|
|
||||||
const commandPalette = useCommandPalette()
|
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const breadcrumbOverride = useBreadcrumbOverride()
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
@@ -45,54 +35,11 @@ export function TopBar({
|
|||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Center: consumer-provided controls */}
|
||||||
<button
|
{children}
|
||||||
className={styles.search}
|
|
||||||
onClick={() => commandPalette.setOpen(true)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Open search"
|
|
||||||
>
|
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
|
||||||
<Search size={13} />
|
|
||||||
</span>
|
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Status filter group */}
|
{/* Right: theme toggle, env badge, user */}
|
||||||
<ButtonGroup
|
|
||||||
items={STATUS_ITEMS}
|
|
||||||
value={globalFilters.statusFilters}
|
|
||||||
onChange={(selected) => {
|
|
||||||
// Sync with global filter by toggling the diff
|
|
||||||
const current = globalFilters.statusFilters
|
|
||||||
for (const v of selected) {
|
|
||||||
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
|
||||||
}
|
|
||||||
for (const v of current) {
|
|
||||||
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Time range pills */}
|
|
||||||
<TimeRangeDropdown
|
|
||||||
value={globalFilters.timeRange}
|
|
||||||
onChange={globalFilters.setTimeRange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
<button
|
|
||||||
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
|
||||||
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
|
||||||
type="button"
|
|
||||||
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
|
||||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
|
||||||
>
|
|
||||||
<span className={styles.liveDot} />
|
|
||||||
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
@@ -114,6 +61,8 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
|
...(userMenuItems ?? []),
|
||||||
|
...(userMenuItems?.length ? [{ label: '', divider: true }] : []),
|
||||||
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export { SidebarTree } from './Sidebar/SidebarTree'
|
|||||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||||
export { useStarred } from './Sidebar/useStarred'
|
export { useStarred } from './Sidebar/useStarred'
|
||||||
export { TopBar } from './TopBar/TopBar'
|
export { TopBar } from './TopBar/TopBar'
|
||||||
|
export { SearchTrigger } from './TopBar/SearchTrigger'
|
||||||
|
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sm { width: 24px; height: 24px; font-size: 9px; }
|
.sm { width: 24px; height: 24px; font-size: 9px; }
|
||||||
.md { width: 28px; height: 28px; font-size: 11px; }
|
.md { width: 28px; height: 28px; font-size: 12px; }
|
||||||
.lg { width: 40px; height: 40px; font-size: 14px; }
|
.lg { width: 40px; height: 40px; font-size: 14px; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
.sm { padding: 4px 10px; font-size: 11px; }
|
.sm { padding: 4px 10px; font-size: 12px; }
|
||||||
.md { padding: 6px 14px; font-size: 12px; }
|
.md { padding: 6px 14px; font-size: 12px; }
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.titleText {
|
.titleText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -49,5 +49,5 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dayHeader {
|
.dayHeader {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeLabel {
|
.timeLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|||||||
64
src/design-system/primitives/FileInput/FileInput.module.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap:hover {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragOver {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearBtn {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearBtn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
99
src/design-system/primitives/FileInput/FileInput.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import styles from './FileInput.module.css'
|
||||||
|
import { forwardRef, useRef, useState, useImperativeHandle, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface FileInputProps {
|
||||||
|
/** File type filter, e.g. ".pem,.crt,.cer" */
|
||||||
|
accept?: string
|
||||||
|
/** Icon rendered before the label */
|
||||||
|
icon?: ReactNode
|
||||||
|
/** Placeholder text when no file is selected */
|
||||||
|
placeholder?: string
|
||||||
|
/** Additional CSS class */
|
||||||
|
className?: string
|
||||||
|
/** Called when a file is selected or cleared */
|
||||||
|
onChange?: (file: File | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInputHandle {
|
||||||
|
/** The currently selected File, or null */
|
||||||
|
file: File | null
|
||||||
|
/** Programmatically clear the selection */
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileInput = forwardRef<FileInputHandle, FileInputProps>(
|
||||||
|
({ accept, icon, placeholder = 'Drop file or click to browse', className, onChange }, ref) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [fileName, setFileName] = useState<string | null>(null)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const fileRef = useRef<File | null>(null)
|
||||||
|
|
||||||
|
function select(file: File | null) {
|
||||||
|
fileRef.current = file
|
||||||
|
setFileName(file?.name ?? null)
|
||||||
|
onChange?.(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange() {
|
||||||
|
select(inputRef.current?.files?.[0] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file && inputRef.current) {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.items.add(file)
|
||||||
|
inputRef.current.files = dt.files
|
||||||
|
select(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
select(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
get file() { return fileRef.current },
|
||||||
|
clear() {
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
select(null)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const wrapClass = [styles.wrap, dragOver && styles.dragOver, className].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={wrapClass}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{icon && <span className={styles.icon}>{icon}</span>}
|
||||||
|
<span className={`${styles.label} ${fileName ? styles.fileName : styles.placeholder}`}>
|
||||||
|
{fileName ?? placeholder}
|
||||||
|
</span>
|
||||||
|
{fileName && (
|
||||||
|
<button type="button" className={styles.clearBtn} onClick={handleClear} aria-label="Clear file">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
FileInput.displayName = 'FileInput'
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.kbd {
|
.kbd {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.mono { font-family: var(--font-mono); }
|
.mono { font-family: var(--font-mono); }
|
||||||
.xs { font-size: 10px; }
|
.xs { font-size: 12px; }
|
||||||
.sm { font-size: 11px; }
|
.sm { font-size: 12px; }
|
||||||
.md { font-size: 13px; }
|
.md { font-size: 13px; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
|
|||||||
@@ -35,6 +35,6 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,8 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trend {
|
.trend {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
.trend.neutral { color: var(--text-muted); }
|
.trend.neutral { color: var(--text-muted); }
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
color: var(--bg-surface);
|
color: var(--bg-surface);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export { Collapsible } from './Collapsible/Collapsible'
|
|||||||
export { DateRangePicker } from './DateRangePicker/DateRangePicker'
|
export { DateRangePicker } from './DateRangePicker/DateRangePicker'
|
||||||
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
|
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
|
||||||
export { EmptyState } from './EmptyState/EmptyState'
|
export { EmptyState } from './EmptyState/EmptyState'
|
||||||
|
export { FileInput } from './FileInput/FileInput'
|
||||||
|
export type { FileInputProps, FileInputHandle } from './FileInput/FileInput'
|
||||||
export { FilterPill } from './FilterPill/FilterPill'
|
export { FilterPill } from './FilterPill/FilterPill'
|
||||||
export { FormField } from './FormField/FormField'
|
export { FormField } from './FormField/FormField'
|
||||||
export { InfoCallout } from './InfoCallout/InfoCallout'
|
export { InfoCallout } from './InfoCallout/InfoCallout'
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
/* Typography */
|
/* Typography */
|
||||||
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
--font-size-min: 12px;
|
||||||
|
|
||||||
/* Spacing & Radii */
|
/* Spacing & Radii */
|
||||||
--radius-sm: 5px;
|
--radius-sm: 5px;
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
import { CHART_COLORS } from '../composites/_chart-utils'
|
export const CHART_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
'var(--chart-6)',
|
||||||
|
'var(--chart-7)',
|
||||||
|
'var(--chart-8)',
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-configured Recharts prop objects that match the design system's
|
* Pre-configured Recharts prop objects that match the design system's
|
||||||
* chart styling. Spread these onto Recharts sub-components:
|
* chart styling. Used internally by ThemedChart and available for
|
||||||
*
|
* consumers composing Recharts directly.
|
||||||
* ```tsx
|
|
||||||
* import { rechartsTheme, CHART_COLORS } from '@cameleer/design-system'
|
|
||||||
* import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'
|
|
||||||
*
|
|
||||||
* <LineChart data={data}>
|
|
||||||
* <CartesianGrid {...rechartsTheme.cartesianGrid} />
|
|
||||||
* <XAxis dataKey="name" {...rechartsTheme.xAxis} />
|
|
||||||
* <YAxis {...rechartsTheme.yAxis} />
|
|
||||||
* <Tooltip {...rechartsTheme.tooltip} />
|
|
||||||
* <Legend {...rechartsTheme.legend} />
|
|
||||||
* <Line stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
|
|
||||||
* </LineChart>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const rechartsTheme = {
|
export const rechartsTheme = {
|
||||||
colors: CHART_COLORS,
|
colors: CHART_COLORS,
|
||||||
|
|||||||
@@ -3,34 +3,7 @@ import { exchanges, type Exchange } from './exchanges'
|
|||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
import { agents } from './agents'
|
import { agents } from './agents'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
|
||||||
|
import { formatDuration, statusLabel, statusToVariant, formatTimestamp } from '../utils/format-utils'
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'OK'
|
|
||||||
case 'failed': return 'ERR'
|
|
||||||
case 'running': return 'RUN'
|
|
||||||
case 'warning': return 'WARN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function healthToColor(health: SidebarApp['health']): string {
|
function healthToColor(health: SidebarApp['health']): string {
|
||||||
switch (health) {
|
switch (health) {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailLabel {
|
.detailLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entityMeta {
|
.entityMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inheritedNote {
|
.inheritedNote {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
.breakdown {
|
.breakdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
.scopeSep {
|
.scopeSep {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeCurrent {
|
.scopeCurrent {
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionMeta {
|
.sectionMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartTitle {
|
.chartTitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
.scopeSep {
|
.scopeSep {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeCurrent {
|
.scopeCurrent {
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartMeta {
|
.chartMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,14 +128,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inlineErrorText {
|
.inlineErrorText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineErrorHint {
|
.inlineErrorHint {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelSectionTitle {
|
.panelSectionTitle {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
.panelSectionMeta {
|
.panelSectionMeta {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overviewLabel {
|
.overviewLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -209,14 +209,14 @@
|
|||||||
|
|
||||||
.errorClass {
|
.errorClass {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { Badge } from '../../design-system/primitives/Badge/Badge'
|
|||||||
// Global filters
|
// Global filters
|
||||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration, statusToVariant, statusLabel, toRouteNodeType, durationClass } from '../../utils/format-utils'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
||||||
@@ -66,12 +69,6 @@ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
const y = date.getFullYear()
|
const y = date.getFullYear()
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
@@ -82,24 +79,6 @@ function formatTimestamp(date: Date): string {
|
|||||||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'OK'
|
|
||||||
case 'failed': return 'ERR'
|
|
||||||
case 'running': return 'RUN'
|
|
||||||
case 'warning': return 'WARN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Table columns (base, without navigate action) ──────────────────────────
|
// ─── Table columns (base, without navigate action) ──────────────────────────
|
||||||
const BASE_COLUMNS: Column<Exchange>[] = [
|
const BASE_COLUMNS: Column<Exchange>[] = [
|
||||||
{
|
{
|
||||||
@@ -150,7 +129,7 @@ const BASE_COLUMNS: Column<Exchange>[] = [
|
|||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
|
||||||
{formatDuration(row.durationMs)}
|
{formatDuration(row.durationMs)}
|
||||||
</MonoText>
|
</MonoText>
|
||||||
),
|
),
|
||||||
@@ -167,14 +146,6 @@ const BASE_COLUMNS: Column<Exchange>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function durationClass(ms: number, status: Exchange['status']): string {
|
|
||||||
if (status === 'failed') return styles.durBreach
|
|
||||||
if (ms < 100) return styles.durFast
|
|
||||||
if (ms < 200) return styles.durNormal
|
|
||||||
if (ms < 300) return styles.durSlow
|
|
||||||
return styles.durBreach
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ keys: 'Ctrl+K', label: 'Search' },
|
{ keys: 'Ctrl+K', label: 'Search' },
|
||||||
{ keys: '↑↓', label: 'Navigate rows' },
|
{ keys: '↑↓', label: 'Navigate rows' },
|
||||||
@@ -257,16 +228,6 @@ export function Dashboard() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map processor types to RouteNode types
|
|
||||||
function toRouteNodeType(procType: string): RouteNode['type'] {
|
|
||||||
switch (procType) {
|
|
||||||
case 'consumer': return 'from'
|
|
||||||
case 'transform': return 'process'
|
|
||||||
case 'enrich': return 'process'
|
|
||||||
default: return procType as RouteNode['type']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build RouteFlow nodes from exchange processors
|
// Build RouteFlow nodes from exchange processors
|
||||||
const routeNodes: RouteNode[] = selectedExchange
|
const routeNodes: RouteNode[] = selectedExchange
|
||||||
? selectedExchange.processors.map((p) => ({
|
? selectedExchange.processors.map((p) => ({
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.headerStatLabel {
|
.headerStatLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chainLabel {
|
.chainLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
|
|
||||||
.procCount {
|
.procCount {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
|
|
||||||
.toggleBtn {
|
.toggleBtn {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
|
|
||||||
.panelTag {
|
.panelTag {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
grid-template-columns: 140px 1fr;
|
grid-template-columns: 140px 1fr;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerKvRow:last-child {
|
.headerKvRow:last-child {
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionLabel {
|
.sectionLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
|
|
||||||
.errorMessageBox {
|
.errorMessageBox {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--error-bg);
|
background: var(--error-bg);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 1fr;
|
grid-template-columns: 120px 1fr;
|
||||||
gap: 4px 12px;
|
gap: 4px 12px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorDetailLabel {
|
.errorDetailLabel {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges } from '../../mocks/exchanges'
|
import { exchanges } from '../../mocks/exchanges'
|
||||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
@@ -25,21 +28,6 @@ import { buildRouteToAppMap } from '../../mocks/sidebar'
|
|||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
|
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed': return 'COMPLETED'
|
case 'completed': return 'COMPLETED'
|
||||||
@@ -145,16 +133,6 @@ function generateExchangeSnapshotOut(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map processor types to RouteNode types
|
|
||||||
function toRouteNodeType(procType: string): RouteNode['type'] {
|
|
||||||
switch (procType) {
|
|
||||||
case 'consumer': return 'from'
|
|
||||||
case 'transform': return 'process'
|
|
||||||
case 'enrich': return 'process'
|
|
||||||
default: return procType as RouteNode['type']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
||||||
export function ExchangeDetail() {
|
export function ExchangeDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navLabel {
|
.navLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoExport {
|
.logoExport {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import styles from './BrandAssetsSection.module.css'
|
import styles from './BrandAssetsSection.module.css'
|
||||||
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||||
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||||
|
import cameleer3LogoSvg from '../../../assets/cameleer3-logo.svg'
|
||||||
|
|
||||||
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export function BrandAssetsSection() {
|
|||||||
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||||
<p className={styles.componentDesc}>
|
<p className={styles.componentDesc}>
|
||||||
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||||
Shipped as static assets via package exports.
|
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.demoArea}>
|
<div className={styles.demoArea}>
|
||||||
<div className={styles.logoGrid}>
|
<div className={styles.logoGrid}>
|
||||||
@@ -28,7 +29,7 @@ export function BrandAssetsSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.logoLabel}>{size}×{size}</span>
|
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||||
<code className={styles.logoExport}>@cameleer/design-system/logo-{size}</code>
|
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className={styles.logoItem}>
|
<div className={styles.logoItem}>
|
||||||
@@ -41,25 +42,32 @@ export function BrandAssetsSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.logoLabel}>Original</span>
|
<span className={styles.logoLabel}>Original</span>
|
||||||
<code className={styles.logoExport}>@cameleer/design-system/logo</code>
|
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.componentCard}>
|
<div className={styles.componentCard}>
|
||||||
<h3 className={styles.componentTitle}>Camel Logo (SVG)</h3>
|
<h3 className={styles.componentTitle}>SVG Logos</h3>
|
||||||
<p className={styles.componentDesc}>
|
<p className={styles.componentDesc}>
|
||||||
Vector logo for scalable usage. Shipped as a static asset via package export.
|
Vector logos for scalable usage. Transparent backgrounds, infinitely scalable.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.demoArea}>
|
<div className={styles.demoArea}>
|
||||||
<div className={styles.logoGrid}>
|
<div className={styles.logoGrid}>
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img src={cameleer3LogoSvg} alt="Cameleer3 SVG logo" width={96} height={96} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Cameleer3 SVG</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-logo.svg</code>
|
||||||
|
</div>
|
||||||
<div className={styles.logoItem}>
|
<div className={styles.logoItem}>
|
||||||
<div className={styles.logoPreview}>
|
<div className={styles.logoPreview}>
|
||||||
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.logoLabel}>SVG</span>
|
<span className={styles.logoLabel}>Camel SVG</span>
|
||||||
<code className={styles.logoExport}>@cameleer/design-system/logo-svg</code>
|
<code className={styles.logoExport}>assets/camel-logo.svg</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
|
|||||||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||||
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
||||||
|
import { SearchTrigger } from '../../../design-system/layout/TopBar/SearchTrigger'
|
||||||
|
import { AutoRefreshToggle } from '../../../design-system/layout/TopBar/AutoRefreshToggle'
|
||||||
|
|
||||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ export function LayoutSection() {
|
|||||||
<DemoCard
|
<DemoCard
|
||||||
id="topbar"
|
id="topbar"
|
||||||
title="TopBar"
|
title="TopBar"
|
||||||
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar."
|
description="Composable top navigation bar. Consumers pass children for the center slot (search, filters, etc.). Shell provides breadcrumb, theme toggle, env badge, and user menu."
|
||||||
>
|
>
|
||||||
<div className={styles.topbarPreview}>
|
<div className={styles.topbarPreview}>
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -132,9 +134,11 @@ export function LayoutSection() {
|
|||||||
{ label: 'order-ingest' },
|
{ label: 'order-ingest' },
|
||||||
]}
|
]}
|
||||||
environment="production"
|
environment="production"
|
||||||
|
|
||||||
user={{ name: 'Hendrik' }}
|
user={{ name: 'Hendrik' }}
|
||||||
/>
|
>
|
||||||
|
<SearchTrigger onClick={() => {}} />
|
||||||
|
<AutoRefreshToggle active={true} onChange={() => {}} />
|
||||||
|
</TopBar>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demoLabel {
|
.demoLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.routeGroup {
|
.routeGroup {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kpiLabel {
|
.kpiLabel {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionMeta {
|
.sectionMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,14 +228,14 @@
|
|||||||
|
|
||||||
.errorClass {
|
.errorClass {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorText {
|
.errorText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.errorPatternMessage {
|
.errorPatternMessage {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.errorPatternTime {
|
.errorPatternTime {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,39 +17,14 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
|||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration, statusToVariant, statusLabel, formatTimestamp, durationClass } from '../../utils/format-utils'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { routes } from '../../mocks/routes'
|
import { routes } from '../../mocks/routes'
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'success'
|
|
||||||
case 'failed': return 'error'
|
|
||||||
case 'running': return 'running'
|
|
||||||
case 'warning': return 'warning'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: Exchange['status']): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'OK'
|
|
||||||
case 'failed': return 'ERR'
|
|
||||||
case 'running': return 'RUN'
|
|
||||||
case 'warning': return 'WARN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' {
|
function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'healthy': return 'success'
|
case 'healthy': return 'success'
|
||||||
@@ -58,14 +33,6 @@ function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function durationClass(ms: number, status: Exchange['status']): string {
|
|
||||||
if (status === 'failed') return styles.durBreach
|
|
||||||
if (ms < 100) return styles.durFast
|
|
||||||
if (ms < 200) return styles.durNormal
|
|
||||||
if (ms < 300) return styles.durSlow
|
|
||||||
return styles.durBreach
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Columns for exchanges table ────────────────────────────────────────────
|
// ─── Columns for exchanges table ────────────────────────────────────────────
|
||||||
const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
||||||
{
|
{
|
||||||
@@ -106,7 +73,7 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
|||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
|
||||||
{formatDuration(row.durationMs)}
|
{formatDuration(row.durationMs)}
|
||||||
</MonoText>
|
</MonoText>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refreshText {
|
.refreshText {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/utils/format-utils.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { RouteNode } from '../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||||
|
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
return `${ms}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success'
|
||||||
|
case 'failed': return 'error'
|
||||||
|
case 'running': return 'running'
|
||||||
|
case 'warning': return 'warning'
|
||||||
|
default: return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'completed': return 'OK'
|
||||||
|
case 'failed': return 'ERR'
|
||||||
|
case 'running': return 'RUN'
|
||||||
|
case 'warning': return 'WARN'
|
||||||
|
default: return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRouteNodeType(procType: string): RouteNode['type'] {
|
||||||
|
switch (procType) {
|
||||||
|
case 'consumer': return 'from'
|
||||||
|
case 'transform': return 'process'
|
||||||
|
case 'enrich': return 'process'
|
||||||
|
default: return procType as RouteNode['type']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DurationStyles {
|
||||||
|
durBreach: string
|
||||||
|
durFast: string
|
||||||
|
durNormal: string
|
||||||
|
durSlow: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function durationClass(ms: number, status: string, styles: DurationStyles): string {
|
||||||
|
if (status === 'failed') return styles.durBreach
|
||||||
|
if (ms < 100) return styles.durFast
|
||||||
|
if (ms < 200) return styles.durNormal
|
||||||
|
if (ms < 300) return styles.durSlow
|
||||||
|
return styles.durBreach
|
||||||
|
}
|
||||||