Compare commits
No commits in common. "214b714873a16a1b8ea2738ac8664f155ade9d10" and "942373563d31b2657264758802c2f647d029efe1" have entirely different histories.
214b714873
...
942373563d
|
|
@ -126,9 +126,9 @@ Final Validation
|
||||||
input: workingText, keyword map, media map, approved media changes
|
input: workingText, keyword map, media map, approved media changes
|
||||||
output: validation report
|
output: validation report
|
||||||
|
|
||||||
Patch / Export
|
Apply / Export
|
||||||
input: approved text/media changes
|
input: approved text/media changes
|
||||||
output: patch artifact, changeset manifest, reports/export
|
output: changeset, updated target project, reports/export
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Tool Boundaries
|
## 4. Tool Boundaries
|
||||||
|
|
@ -231,23 +231,11 @@ Codex через `codex exec` используется для смысловых
|
||||||
- предложить alt/caption/filename.
|
- предложить alt/caption/filename.
|
||||||
```
|
```
|
||||||
|
|
||||||
Codex не должен напрямую менять target project. Любая правка проходит через patch artifact.
|
Codex не должен напрямую менять target project до `Apply`.
|
||||||
|
|
||||||
## 5. Patch Safety Rules
|
## 5. Apply Safety Rules
|
||||||
|
|
||||||
Исходники сайта всегда считаются read-only для редактора SEO Mode.
|
До ручного Apply target project считается read-only.
|
||||||
|
|
||||||
Рабочий контур:
|
|
||||||
|
|
||||||
```text
|
|
||||||
original source
|
|
||||||
-> original snapshot
|
|
||||||
-> editor draft
|
|
||||||
-> patch artifact near imported source / project storage
|
|
||||||
-> explicit production patch-run after user confirmation
|
|
||||||
```
|
|
||||||
|
|
||||||
Временные черновики, которые видит только редактор, живут в draft storage. Публикуемый результат должен материализоваться как отдельный patch artifact, а не как незаметное изменение source files.
|
|
||||||
|
|
||||||
Разрешённые изменения после approval:
|
Разрешённые изменения после approval:
|
||||||
|
|
||||||
|
|
@ -272,7 +260,7 @@ original source
|
||||||
- публиковать на хостинг.
|
- публиковать на хостинг.
|
||||||
```
|
```
|
||||||
|
|
||||||
Перед production patch-run UI обязан показать:
|
Перед Apply UI обязан показать:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
- affected files;
|
- affected files;
|
||||||
|
|
@ -280,21 +268,18 @@ original source
|
||||||
- media diff;
|
- media diff;
|
||||||
- filename rename plan;
|
- filename rename plan;
|
||||||
- validation status;
|
- validation status;
|
||||||
- patch artifact path/name;
|
- changeset summary.
|
||||||
- changeset summary;
|
|
||||||
- patch-run command/status.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Production patch-run должен идти транзакционно:
|
Apply должен идти транзакционно:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. build patch artifact
|
1. build changeset
|
||||||
2. build changeset manifest
|
2. run dry-run
|
||||||
3. run dry-run against copied/current source tree
|
3. backup affected files to object storage
|
||||||
4. backup affected files to object storage
|
4. apply text/media/filename changes through backend
|
||||||
5. run patch script against source tree only after explicit user confirmation
|
5. verify references and rename results
|
||||||
6. verify references and rename results
|
6. rollback if any critical step fails
|
||||||
7. rollback if any critical step fails
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5.1. Repeated Scan / Reconcile Rules
|
## 5.1. Repeated Scan / Reconcile Rules
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# NDC SEO mod: model provider contract
|
|
||||||
|
|
||||||
Этот документ фиксирует, как подключать модель, чтобы SEO mod не зависел от одного runtime вроде Codex desktop.
|
|
||||||
|
|
||||||
## Главный принцип
|
|
||||||
|
|
||||||
Модель не является владельцем SEO pipeline. Она является исполнителем строго ограниченных задач внутри contract, который формирует SEO mod.
|
|
||||||
|
|
||||||
SEO mod отвечает за:
|
|
||||||
- scan, scope, page identity, section identity;
|
|
||||||
- project ontology и evidence;
|
|
||||||
- market evidence;
|
|
||||||
- keyword/page/field plan;
|
|
||||||
- validation gates;
|
|
||||||
- changeset/apply safety.
|
|
||||||
|
|
||||||
Model runtime отвечает только за:
|
|
||||||
- интерпретацию неоднозначного контекста;
|
|
||||||
- нормализацию seed phrases;
|
|
||||||
- подготовку вариантов текста в approved slots;
|
|
||||||
- объяснение риска и confidence;
|
|
||||||
- structured JSON output по схеме.
|
|
||||||
|
|
||||||
## Provider-agnostic shape
|
|
||||||
|
|
||||||
Целевой слой должен называться условно `ModelProvider`.
|
|
||||||
|
|
||||||
Обязательный interface:
|
|
||||||
- `capabilities`: context window, json mode, tool use, streaming, cost policy, rate limits;
|
|
||||||
- `runStructuredTask(task)`: принять task contract и вернуть validated JSON;
|
|
||||||
- `explainFailure(error)`: нормализовать provider/runtime error;
|
|
||||||
- `estimateCost(task)`: оценить cost/limits до запуска;
|
|
||||||
- `cancel(runId)`: остановить long task.
|
|
||||||
|
|
||||||
Провайдеры:
|
|
||||||
- `codex_workspace`: текущий dev/runtime через AI Workspace/Codex-like rules;
|
|
||||||
- `openai_api`: production token-based model access;
|
|
||||||
- `local_model`: локальная модель для private/on-prem;
|
|
||||||
- `external_agent`: enterprise adapter через AI Workspace Hub или MCP-compatible gateway.
|
|
||||||
|
|
||||||
## MCP / AI Workspace boundary
|
|
||||||
|
|
||||||
MCP не должен быть местом бизнес-логики SEO.
|
|
||||||
|
|
||||||
MCP/AI Workspace может давать модели:
|
|
||||||
- read-only контекст проекта;
|
|
||||||
- approved task contract;
|
|
||||||
- инструменты для чтения artifacts;
|
|
||||||
- tools для draft-only output.
|
|
||||||
|
|
||||||
MCP/AI Workspace не должен давать модели:
|
|
||||||
- прямой apply в source files;
|
|
||||||
- возможность менять project ontology truth без review;
|
|
||||||
- возможность писать changeset без validation;
|
|
||||||
- доступ к secrets без platform permission layer;
|
|
||||||
- свободный crawl/write вне выбранного scope.
|
|
||||||
|
|
||||||
## Когда модель нужна
|
|
||||||
|
|
||||||
1. Context Review
|
|
||||||
- уточнить реальный смысл сайта, если текст или разметка плохие;
|
|
||||||
- найти ambiguity и low-confidence места;
|
|
||||||
- предложить seed phrases с evidence references;
|
|
||||||
- не писать финальный SEO-текст.
|
|
||||||
|
|
||||||
2. Seed Review
|
|
||||||
- нормализовать фразы;
|
|
||||||
- убрать слишком широкие или выдуманные фразы;
|
|
||||||
- объяснить, почему фраза привязана к intent/page.
|
|
||||||
|
|
||||||
3. Model Review
|
|
||||||
- предложить rewrite variants только по approved diff slots;
|
|
||||||
- не менять бизнес-вектор;
|
|
||||||
- не создавать новые страницы без materialization decision;
|
|
||||||
- вернуть before/after rationale and risk flags.
|
|
||||||
|
|
||||||
4. Validation Assist
|
|
||||||
- объяснить validation warnings;
|
|
||||||
- предложить безопасные правки для accepted draft;
|
|
||||||
- не override hard blockers.
|
|
||||||
|
|
||||||
## Task contract requirements
|
|
||||||
|
|
||||||
Каждый model task должен содержать:
|
|
||||||
- `taskType`;
|
|
||||||
- `schemaVersion`;
|
|
||||||
- `projectOntologyVersionId`;
|
|
||||||
- `scopeContract`;
|
|
||||||
- `evidenceRefs`;
|
|
||||||
- `forbiddenClaims`;
|
|
||||||
- `styleProfile`;
|
|
||||||
- `allowedActions`;
|
|
||||||
- `outputSchema`;
|
|
||||||
- `confidencePolicy`;
|
|
||||||
- `stopConditions`.
|
|
||||||
|
|
||||||
Модель должна возвращать:
|
|
||||||
- `schemaVersion`;
|
|
||||||
- `confidence`;
|
|
||||||
- `evidenceRefsUsed`;
|
|
||||||
- `changes` или `proposals`;
|
|
||||||
- `risks`;
|
|
||||||
- `needsHumanReview`;
|
|
||||||
- `refusalReason`, если contract запрещает уверенный ответ.
|
|
||||||
|
|
||||||
## Product rule
|
|
||||||
|
|
||||||
До подключения production provider мы можем симулировать model output через deterministic contracts и manual review. Но все contracts уже должны выглядеть так, будто завтра вместо Codex придет OpenAI API, local model или enterprise AI Workspace adapter.
|
|
||||||
|
|
@ -31,10 +31,9 @@
|
||||||
- `USER_FLOW.md` — пользовательский сценарий.
|
- `USER_FLOW.md` — пользовательский сценарий.
|
||||||
- `IMPLEMENTATION_GUARDRAILS.md` — pipeline, границы инструментов и риски.
|
- `IMPLEMENTATION_GUARDRAILS.md` — pipeline, границы инструментов и риски.
|
||||||
- `UI_UX_SPEC.md` — UI/UX экраны, состояния и safety-паттерны.
|
- `UI_UX_SPEC.md` — UI/UX экраны, состояния и safety-паттерны.
|
||||||
|
- `TASK_MANAGER.md` — поэтапный план реализации.
|
||||||
- `CHECK_OVERSEO_SPEC.md` — спецификация локальной проверки переспама.
|
- `CHECK_OVERSEO_SPEC.md` — спецификация локальной проверки переспама.
|
||||||
|
|
||||||
Актуальные задачи, статусы и следующий маршрут ведутся в Ops-карточках проекта `NDC PLATFORM`.
|
|
||||||
|
|
||||||
## Зафиксированные архитектурные решения
|
## Зафиксированные архитектурные решения
|
||||||
|
|
||||||
- `Postgres` — основной источник истины.
|
- `Postgres` — основной источник истины.
|
||||||
|
|
|
||||||
|
|
@ -191,10 +191,10 @@ For MVP we start with `local_folder`, but the application should keep the source
|
||||||
18. UI shows report and diff
|
18. UI shows report and diff
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
19. User materializes approved patch artifact
|
19. User applies changes to target project
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
20. User runs production patch-run or exports archive/reports
|
20. User can export archive/reports
|
||||||
```
|
```
|
||||||
|
|
||||||
MVP works with existing code/text only:
|
MVP works with existing code/text only:
|
||||||
|
|
@ -298,12 +298,11 @@ app/
|
||||||
-> ru-text status
|
-> ru-text status
|
||||||
-> media SEO status
|
-> media SEO status
|
||||||
|
|
||||||
Patch / Export
|
Apply / Export
|
||||||
-> affected files
|
-> affected files
|
||||||
-> patch artifact
|
-> changeset
|
||||||
-> changeset manifest
|
|
||||||
-> manual approval
|
-> manual approval
|
||||||
-> production patch-run
|
-> apply to target project
|
||||||
-> zip target project
|
-> zip target project
|
||||||
-> export reports
|
-> export reports
|
||||||
|
|
||||||
|
|
@ -796,12 +795,14 @@ extract text
|
||||||
v
|
v
|
||||||
changeset
|
changeset
|
||||||
|
|
|
|
||||||
+-- patch artifact near imported source / project storage
|
|
||||||
+-- dry-run
|
+-- dry-run
|
||||||
+-- backup affected files to object storage
|
+-- backup affected files to object storage
|
||||||
+-- explicit production patch-run
|
+-- apply through backend
|
||||||
+-- verify references/renames
|
+-- verify references/renames
|
||||||
+-- rollback on failure
|
+-- rollback on failure
|
||||||
|
|
|
||||||
|
v
|
||||||
|
apply to target project
|
||||||
```
|
```
|
||||||
|
|
||||||
MVP stores one `workingText` draft per source/section. Full draft versioning and rollback history are future enhancements.
|
MVP stores one `workingText` draft per source/section. Full draft versioning and rollback history are future enhancements.
|
||||||
|
|
@ -815,8 +816,7 @@ Related local docs:
|
||||||
- `CHECK_OVERSEO_SPEC.md`
|
- `CHECK_OVERSEO_SPEC.md`
|
||||||
- `IMPLEMENTATION_GUARDRAILS.md`
|
- `IMPLEMENTATION_GUARDRAILS.md`
|
||||||
- `UI_UX_SPEC.md`
|
- `UI_UX_SPEC.md`
|
||||||
|
- `TASK_MANAGER.md`
|
||||||
Актуальные задачи и статусы разработки ведутся в Ops-карточках проекта `NDC PLATFORM`.
|
|
||||||
|
|
||||||
- Codex exec: https://developers.openai.com/codex/noninteractive
|
- Codex exec: https://developers.openai.com/codex/noninteractive
|
||||||
- Codex with ChatGPT plan: https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan
|
- Codex with ChatGPT plan: https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan
|
||||||
|
|
|
||||||
|
|
@ -302,30 +302,28 @@ Codex получает структурированное задание:
|
||||||
|
|
||||||
Важно: `Apply` нельзя делать после media-правок без повторной финальной проверки.
|
Важно: `Apply` нельзя делать после media-правок без повторной финальной проверки.
|
||||||
|
|
||||||
### 3.14. Patch / Export
|
### 3.14. Apply / Export
|
||||||
|
|
||||||
Перед materialize/production patch-run UI показывает:
|
Перед применением UI показывает:
|
||||||
|
|
||||||
- affected files;
|
- affected files;
|
||||||
- affected sections;
|
- affected sections;
|
||||||
- text diff;
|
- text diff;
|
||||||
- media diff;
|
- media diff;
|
||||||
- filename rename plan;
|
- filename rename plan;
|
||||||
- patch artifact path/name;
|
|
||||||
- validation status.
|
- validation status.
|
||||||
|
|
||||||
Пользователь подтверждает создание patch artifact. Исходники сайта при этом не меняются. Production меняется только отдельным явным patch-run шагом, когда пользователь подтверждает, что выбранный patch можно запускать по source tree.
|
Пользователь нажимает `Apply to target project`. После apply создаётся changeset и сохраняется история.
|
||||||
|
|
||||||
Безопасный patch-пайплайн:
|
Безопасный apply-пайплайн:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. собрать patch artifact
|
1. собрать changeset
|
||||||
2. собрать changeset manifest
|
2. сделать dry-run
|
||||||
3. сделать dry-run против текущего source tree
|
3. сохранить backup affected files в object storage
|
||||||
4. сохранить backup affected files в object storage
|
4. применить изменения через backend
|
||||||
5. запустить patch script только после явного подтверждения production patch-run
|
5. проверить rename/update references
|
||||||
6. проверить rename/update references
|
6. если что-то упало, выполнить rollback
|
||||||
7. если что-то упало, выполнить rollback
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Export:
|
Export:
|
||||||
|
|
@ -1020,5 +1018,4 @@ Media SEO не даёт забыть alt, poster, видео-контекст и
|
||||||
- `CHECK_OVERSEO_SPEC.md` — спецификация локального анти-переспам инструмента.
|
- `CHECK_OVERSEO_SPEC.md` — спецификация локального анти-переспам инструмента.
|
||||||
- `IMPLEMENTATION_GUARDRAILS.md` — порядок backend pipeline, границы инструментов и риски разработки.
|
- `IMPLEMENTATION_GUARDRAILS.md` — порядок backend pipeline, границы инструментов и риски разработки.
|
||||||
- `UI_UX_SPEC.md` — обязательные UI/UX-экраны, компоненты, состояния и safety-паттерны.
|
- `UI_UX_SPEC.md` — обязательные UI/UX-экраны, компоненты, состояния и safety-паттерны.
|
||||||
|
- `TASK_MANAGER.md` — поэтапный план реализации с подзадачами и критериями готовности.
|
||||||
Актуальные задачи, статусы и критерии следующего этапа ведутся в Ops-карточках проекта `NDC PLATFORM`.
|
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
# NDC SEO mod: UX contract стадий 5-8
|
|
||||||
|
|
||||||
Этот документ фиксирует продуктовый смысл середины SEO-pipeline до model review. Цель: оставить Dev Mode доказательным, но не превращать GUI в сырую консоль SEO-инженера.
|
|
||||||
|
|
||||||
## Stage 5: Рыночный спрос
|
|
||||||
|
|
||||||
Вопрос пользователя: есть ли реальный поисковый спрос по темам сайта?
|
|
||||||
|
|
||||||
Основная ценность:
|
|
||||||
- показать, какие semantic seeds подтверждены внешним market evidence;
|
|
||||||
- показать, какие фразы слабые, шумные, related-only или пустые;
|
|
||||||
- отделить deterministic site meaning от реального спроса;
|
|
||||||
- подготовить только evidence-backed inputs для SEO-плана страниц.
|
|
||||||
|
|
||||||
Dev-details могут показывать Wordstat job, provider readiness, raw rows и external-service gaps. Основной экран не должен выглядеть так, будто строки Wordstat уже являются финальными SEO-решениями.
|
|
||||||
|
|
||||||
Выход этапа:
|
|
||||||
- подтвержденные сигналы спроса;
|
|
||||||
- фразы, требующие проверки;
|
|
||||||
- фразы, которые нельзя двигать дальше;
|
|
||||||
- недостающие evidence для уверенного решения.
|
|
||||||
|
|
||||||
## Stage 6: SEO-план страниц
|
|
||||||
|
|
||||||
Вопрос пользователя: что именно SEO-модуль будет улучшать, где и почему?
|
|
||||||
|
|
||||||
Основная ценность:
|
|
||||||
- разложить approved phrases по страницам, секциям и полям;
|
|
||||||
- определить роль: главный интент, дополнительный, поддержка или проверка;
|
|
||||||
- задать лимиты естественного использования;
|
|
||||||
- сохранить причину и evidence-link для каждого решения;
|
|
||||||
- собрать контролируемый contract перед diff/model review.
|
|
||||||
|
|
||||||
Это не ручная таблица ключей. Пользователь подтверждает или корректирует предложенные назначения.
|
|
||||||
|
|
||||||
Выход этапа:
|
|
||||||
- page-level SEO plan;
|
|
||||||
- field-level bindings: title, description, H1, body section;
|
|
||||||
- materialization queue для planned pages, которых нет в scan;
|
|
||||||
- blockers перед rewrite diff.
|
|
||||||
|
|
||||||
## Stage 7: Model review
|
|
||||||
|
|
||||||
Вопрос пользователя: корректны ли предложенные изменения текста, не уехал ли смысл и есть ли польза?
|
|
||||||
|
|
||||||
Основная ценность:
|
|
||||||
- ревьюить model-generated variants только после approved SEO-плана;
|
|
||||||
- показывать before/after diff по полям и секциям;
|
|
||||||
- объяснять, какой intent или keyword закрывает каждое изменение;
|
|
||||||
- держать в контексте forbidden claims, project ontology, tone и style limits;
|
|
||||||
- требовать human acceptance перед validation/apply.
|
|
||||||
|
|
||||||
Выбор модели, provider, plugins и runtime намеренно не решается здесь. Это обсуждаем отдельно только когда diff contract и review interface будут готовы.
|
|
||||||
|
|
||||||
Выход этапа:
|
|
||||||
- accepted/rejected/edited rewrite variants;
|
|
||||||
- unresolved model risks;
|
|
||||||
- final working draft candidates для validation.
|
|
||||||
|
|
||||||
## Stage 8: Quality gate
|
|
||||||
|
|
||||||
Вопрос пользователя: можно ли безопасно применять эти изменения?
|
|
||||||
|
|
||||||
Основная ценность:
|
|
||||||
- блокировать apply на hard SEO, meaning, style или safety errors;
|
|
||||||
- отделять soft warnings от blockers;
|
|
||||||
- проверять, что не появились unsupported claims или legacy context;
|
|
||||||
- проверять anti-spam, ru-text, structure и pending diff state;
|
|
||||||
- давать ясный final apply readiness verdict.
|
|
||||||
|
|
||||||
Выход этапа:
|
|
||||||
- can apply / cannot apply;
|
|
||||||
- hard blockers;
|
|
||||||
- accepted warnings;
|
|
||||||
- changeset candidate для dry-run/apply.
|
|
||||||
|
|
||||||
## Product rule
|
|
||||||
|
|
||||||
Dev Mode может показывать raw data, но каждый видимый stage сначала должен отвечать на пользовательское решение. Raw evidence уходит в drill-down details, а не становится главной ценностью экрана.
|
|
||||||
|
|
@ -694,9 +694,9 @@ Soft warnings:
|
||||||
- low confidence classification;
|
- low confidence classification;
|
||||||
- stylistic warning.
|
- stylistic warning.
|
||||||
|
|
||||||
### 5.14. Patch / Export
|
### 5.14. Apply / Export
|
||||||
|
|
||||||
Цель: показать все изменения, материализовать patch artifact и запускать production patch-run только после отдельного подтверждения.
|
Цель: показать все изменения и применить их только после подтверждения.
|
||||||
|
|
||||||
Обязательные элементы:
|
Обязательные элементы:
|
||||||
|
|
||||||
|
|
@ -708,14 +708,12 @@ Soft warnings:
|
||||||
- media diff;
|
- media diff;
|
||||||
- filename rename plan;
|
- filename rename plan;
|
||||||
- validation status;
|
- validation status;
|
||||||
- patch artifact path/name;
|
|
||||||
- changeset summary;
|
- changeset summary;
|
||||||
- confirmation checkbox;
|
- confirmation checkbox;
|
||||||
- Build Patch button;
|
- Apply button;
|
||||||
- Run Patch button;
|
|
||||||
- Export button.
|
- Export button.
|
||||||
|
|
||||||
Build Patch должен быть заблокирован, если:
|
Apply должен быть заблокирован, если:
|
||||||
|
|
||||||
- нет финальной проверки;
|
- нет финальной проверки;
|
||||||
- есть hard errors;
|
- есть hard errors;
|
||||||
|
|
@ -723,13 +721,6 @@ Build Patch должен быть заблокирован, если:
|
||||||
- pending changes пустые;
|
- pending changes пустые;
|
||||||
- пользователь не подтвердил changeset.
|
- пользователь не подтвердил changeset.
|
||||||
|
|
||||||
Run Patch должен быть заблокирован, если:
|
|
||||||
|
|
||||||
- patch artifact не создан;
|
|
||||||
- dry-run не прошёл;
|
|
||||||
- пользователь не подтвердил production patch-run;
|
|
||||||
- source tree изменился после dry-run.
|
|
||||||
|
|
||||||
Export:
|
Export:
|
||||||
|
|
||||||
- zip target project;
|
- zip target project;
|
||||||
|
|
|
||||||
|
|
@ -639,4 +639,4 @@ Projects
|
||||||
|
|
||||||
UI/UX-набор обязательных экранов, компонентов и состояний вынесен в `UI_UX_SPEC.md`.
|
UI/UX-набор обязательных экранов, компонентов и состояний вынесен в `UI_UX_SPEC.md`.
|
||||||
|
|
||||||
Актуальный пошаговый план, статусы и критерии готовности ведутся в Ops-карточках проекта `NDC PLATFORM`.
|
Пошаговый план реализации с критериями готовности вынесен в `TASK_MANAGER.md`.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 uixmat
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<p align="center">
|
|
||||||
<img alt="vercel-oss 2" src="https://github.com/user-attachments/assets/e6cd5d4c-7707-4271-96cb-10122d193048" />
|
|
||||||
</p>
|
|
||||||
</br>
|
|
||||||
<p align="center">
|
|
||||||
A collection of Open Source charts and utility components that you can customize and extend.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<a href="https://github.com/bklit/bklit-ui"><img src="https://www.shieldcn.dev/github/stars/bklit/bklit-ui.svg?variant=secondary&size=xs&theme=zinc" alt="GitHub Stars" /></a>
|
|
||||||
<a href="https://github.com/bklit/bklit-ui/actions"><img src="https://www.shieldcn.dev/github/ci/bklit/bklit-ui.svg?variant=secondary&size=xs&theme=zinc" alt="CI" /></a>
|
|
||||||
<a href="https://discord.gg/75s4frfE8X"><img src="https://www.shieldcn.dev/discord/members/75s4frfE8X.svg?variant=secondary&size=xs&theme=zinc" alt="Discord Members" /></a>
|
|
||||||
<a href="https://x.com/uixmat"><img src="https://www.shieldcn.dev/x/follow/uixmat.svg?variant=branded&size=xs&theme=zinc" alt="X Follow" /></a>
|
|
||||||
<a href="https://vercel.com/oss"><img src="https://shieldcn.dev/badge/Vercel_OSS_Program_Member.svg?variant=branded&size=xs&theme=zinc&logo=vercel" alt="Vercel OSS Program Member" /></a>
|
|
||||||
</p>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
## Premium Sponsors
|
|
||||||
|
|
||||||
Bklit UI is supported by our premium sponsors.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<a href="https://openpanel.dev">
|
|
||||||
<img alt="OpenPanel" src="https://github.com/user-attachments/assets/279ef3ca-ed28-4a75-bac7-3fb90db4954d" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
**[OpenPanel](https://openpanel.dev)** — OpenPanel is an open source analytics platform that combines web analytics and product analytics in one privacy-first tool. Track pageviews, events, funnels, retention, and user journeys — all without cookies.
|
|
||||||
|
|
||||||
## Charts
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/area-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/area-chart.svg" alt="Area Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Area Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/area-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=area-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/bar-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/bar-chart.svg" alt="Bar Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Bar Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/bar-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=bar-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/candlestick-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/candlestick-chart.svg" alt="Candlestick Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Candlestick Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/candlestick-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=candlestick-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/choropleth-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/choropleth-chart.svg" alt="Choropleth Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Choropleth Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/choropleth-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=choropleth-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/composed-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/composed-chart.svg" alt="Composed Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Composed Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/composed-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=composed-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/funnel-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/funnel-chart.svg" alt="Funnel Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Funnel Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/funnel-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=funnel-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/gauge-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/gauge-chart.svg" alt="Gauge" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Gauge</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/gauge-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=gauge-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/line-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/line-chart.svg" alt="Line Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Line Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/line-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=line-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/live-line-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/live-line-chart.svg" alt="Live Line Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Live Line Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/live-line-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=live-line-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/pie-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/pie-chart.svg" alt="Pie Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Pie Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/pie-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=pie-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/radar-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/radar-chart.svg" alt="Radar Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Radar Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/radar-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=radar-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/ring-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/ring-chart.svg" alt="Ring Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Ring Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/ring-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=ring-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/scatter-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/scatter-chart.svg" alt="Scatter Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Scatter Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/scatter-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=scatter-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top" width="33%">
|
|
||||||
<a href="https://ui.bklit.com/docs/components/sankey-chart">
|
|
||||||
<img src="https://github.com/bklit/bklit-ui/raw/main/apps/web/public/svgs/sankey-chart.svg" alt="Sankey Chart" width="280" />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<strong>Sankey Chart</strong><br />
|
|
||||||
<a href="https://ui.bklit.com/docs/components/sankey-chart">Docs</a> · <a href="https://ui.bklit.com/studio?chart=sankey-chart">Studio</a>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Studio
|
|
||||||
|
|
||||||
Studio is Bklit's interactive chart playground. Pick a chart, tweak styling and animation controls in real time, then copy the generated React code or export a registry JSON for your project.
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.youtube.com/watch?v=I3PLqHImYbE"><img src="https://img.youtube.com/vi/I3PLqHImYbE/maxresdefault.jpg" alt="Bklit Studio" width="720" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[Open Studio →](https://ui.bklit.com/studio)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
Bklit UI is a shadcn registry. Initialize shadcn in your project, then add a chart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest init
|
|
||||||
npx shadcn@latest add @bklit/line-chart
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Installation](https://ui.bklit.com/docs/installation) for registry setup and all components.
|
|
||||||
|
|
||||||
## Repo activity
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
**Chart components** (`packages/ui`, shadcn registry) are [MIT licensed](./LICENSE).
|
|
||||||
|
|
||||||
**Bklit Studio** (`packages/studio`, hosted at [ui.bklit.com/studio](https://ui.bklit.com/studio))
|
|
||||||
is proprietary. See [LICENSE-STUDIO.md](./LICENSE-STUDIO.md). You may not reuse,
|
|
||||||
resell, or redistribute Studio without written permission.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# Bklit UI Vendor Snapshot
|
|
||||||
|
|
||||||
Source: https://github.com/bklit/bklit-ui
|
|
||||||
Commit: 86149e76b509b661aca26af95461b18952eda646
|
|
||||||
Snapshot date: 2026-06-28
|
|
||||||
|
|
||||||
Included:
|
|
||||||
|
|
||||||
- `LICENSE`
|
|
||||||
- `README.md`
|
|
||||||
- `packages/ui`
|
|
||||||
- `apps/web/public/svgs`
|
|
||||||
|
|
||||||
Excluded:
|
|
||||||
|
|
||||||
- `.git`
|
|
||||||
- `node_modules`
|
|
||||||
- `apps/studio`
|
|
||||||
- `packages/studio`
|
|
||||||
|
|
||||||
License notes:
|
|
||||||
|
|
||||||
- The root Bklit UI repository is MIT licensed.
|
|
||||||
- Bklit Studio has a separate restrictive license and is intentionally not vendored here.
|
|
||||||
- Treat this directory as a local source/reference snapshot. Do not import it into the runtime directly until the selected chart components and their dependencies are copied into the app layer intentionally.
|
|
||||||
|
|
||||||
Candidate SEO module uses:
|
|
||||||
|
|
||||||
- `funnel-chart`: pipeline/readiness from context review to market demand, SEO plan, rewrite, validation.
|
|
||||||
- `bar-chart`: top clusters, pages, intents, and demand buckets.
|
|
||||||
- `scatter-chart`: opportunity map by demand, relevance, confidence, and implementation effort.
|
|
||||||
- `sankey-chart`: context/intent to keyword/page/field flow for dev-mode explainability.
|
|
||||||
- `gauge-chart` or `ring-chart`: compact quality/readiness scores.
|
|
||||||
- `area-chart`, `line-chart`, `composed-chart`: historical demand, forecast, and validation trend screens after time-series data exists.
|
|
||||||
- `heatmap-chart`: keyword/page coverage matrix if the table becomes too dense.
|
|
||||||
|
Before Width: | Height: | Size: 995 KiB |
|
Before Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 1000 KiB |
|
Before Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 1001 KiB |
|
Before Width: | Height: | Size: 1018 KiB |
|
Before Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 988 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 988 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 1005 KiB |
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// biome-ignore lint/performance/noBarrelFile: ESLint config re-export
|
|
||||||
export { config as default } from "@bklitui/eslint-config/react-internal";
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@bklitui/ui",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"private": true,
|
|
||||||
"sideEffects": false,
|
|
||||||
"exports": {
|
|
||||||
"./charts": "./src/charts/index.ts",
|
|
||||||
"./lib/utils": "./src/lib/utils.ts",
|
|
||||||
"./components/shimmering-text": "./src/components/shimmering-text.tsx",
|
|
||||||
"./button": "./src/button.tsx",
|
|
||||||
"./card": "./src/card.tsx",
|
|
||||||
"./code": "./src/code.tsx",
|
|
||||||
"./chart-grid": "./src/chart-grid.tsx",
|
|
||||||
"./chart-marker": "./src/chart-marker.tsx",
|
|
||||||
"./line-chart": "./src/line-chart.tsx",
|
|
||||||
"./tooltip": "./src/tooltip.tsx",
|
|
||||||
"./*": "./src/*.tsx"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint . --max-warnings 0",
|
|
||||||
"check-types": "tsc --noEmit",
|
|
||||||
"test": "node scripts/run-tests.mjs",
|
|
||||||
"registry:build": "node scripts/verify-registry-files.mjs && node scripts/verify-registry-deps.mjs && node scripts/generate-v0-examples.mjs && node scripts/generate-block-registry.mjs && shadcn build --output ../../apps/web/public/r && cp registry.json ../../apps/web/public/r/registry.json",
|
|
||||||
"registry:verify": "node scripts/verify-registry-files.mjs && node scripts/verify-registry-deps.mjs && node scripts/verify-registry-sync.mjs"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@bklitui/eslint-config": "workspace:*",
|
|
||||||
"@bklitui/typescript-config": "workspace:*",
|
|
||||||
"@types/d3-array": "^3.2.1",
|
|
||||||
"@types/d3-geo": "^3.1.0",
|
|
||||||
"@types/d3-sankey": "^0.12.5",
|
|
||||||
"@types/d3-scale": "^4.0.9",
|
|
||||||
"@types/d3-shape": "^3.1.6",
|
|
||||||
"@types/geojson": "^7946.0.16",
|
|
||||||
"@types/node": "^22.15.3",
|
|
||||||
"@types/react": "19.2.2",
|
|
||||||
"@types/react-dom": "19.2.2",
|
|
||||||
"@types/topojson-client": "^3.1.5",
|
|
||||||
"@types/topojson-specification": "^1.0.5",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"shadcn": "^2.5.0",
|
|
||||||
"tailwindcss": "^4.1.8",
|
|
||||||
"tsx": "^4.19.4",
|
|
||||||
"typescript": "5.9.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@bklitui/icons": "workspace:*",
|
|
||||||
"@base-ui/react": "^1.0.0-alpha.8",
|
|
||||||
"@number-flow/react": "^0.5.4",
|
|
||||||
"@visx/brush": "3.12.0",
|
|
||||||
"@visx/curve": "4.0.1-alpha.0",
|
|
||||||
"@visx/event": "4.0.1-alpha.0",
|
|
||||||
"@visx/geo": "4.0.1-alpha.0",
|
|
||||||
"@visx/gradient": "4.0.1-alpha.0",
|
|
||||||
"@visx/grid": "4.0.1-alpha.0",
|
|
||||||
"@visx/group": "4.0.1-alpha.0",
|
|
||||||
"@visx/heatmap": "4.0.1-alpha.0",
|
|
||||||
"@visx/pattern": "4.0.1-alpha.0",
|
|
||||||
"@visx/responsive": "4.0.1-alpha.0",
|
|
||||||
"@visx/sankey": "4.0.1-alpha.0",
|
|
||||||
"@visx/scale": "4.0.1-alpha.0",
|
|
||||||
"@visx/shape": "4.0.1-alpha.0",
|
|
||||||
"@visx/zoom": "4.0.1-alpha.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"d3-array": "3.2.4",
|
|
||||||
"d3-geo": "3.1.0",
|
|
||||||
"d3-scale": "4.0.2",
|
|
||||||
"d3-shape": "^3.2.0",
|
|
||||||
"motion": "^12.27.0",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-use-measure": "^2.1.7",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"topojson-client": "3.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
ChartStatFlow,
|
|
||||||
LinearGradient,
|
|
||||||
} from "@/components/charts";
|
|
||||||
import { curveCardinal } from "@visx/curve";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { revenueSeries, revenueStats } from "../data/revenue-series";
|
|
||||||
import {
|
|
||||||
StatCardChart,
|
|
||||||
statCardLabelClassName,
|
|
||||||
statCardValueClassName,
|
|
||||||
} from "./stat-card-chart";
|
|
||||||
import {
|
|
||||||
formatStatCardMonth,
|
|
||||||
StatCardHoverBridge,
|
|
||||||
type StatCardHoverState,
|
|
||||||
} from "./stat-card-hover-bridge";
|
|
||||||
import { TrendBadge } from "./trend-badge";
|
|
||||||
|
|
||||||
export function StatCardArea() {
|
|
||||||
const [hover, setHover] = useState<StatCardHoverState>({
|
|
||||||
value: null,
|
|
||||||
label: null,
|
|
||||||
trend: null,
|
|
||||||
});
|
|
||||||
const displayValue = hover.value ?? revenueStats.average;
|
|
||||||
const displayLabel = hover.label ?? "Avg";
|
|
||||||
const displayTrend = hover.trend ?? revenueStats.trend;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="w-full gap-0 py-0">
|
|
||||||
<CardHeader className="px-4 py-3">
|
|
||||||
<CardTitle>Total Revenue</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
<TrendBadge value={displayTrend} />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-3 px-4 pt-2 pb-3">
|
|
||||||
<ChartStatFlow
|
|
||||||
formatOptions={{
|
|
||||||
currency: "USD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
style: "currency",
|
|
||||||
}}
|
|
||||||
label={displayLabel}
|
|
||||||
labelClassName={statCardLabelClassName}
|
|
||||||
value={displayValue}
|
|
||||||
valueClassName={statCardValueClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatCardChart size="md">
|
|
||||||
<AreaChart
|
|
||||||
aspectRatio="2.5 / 1"
|
|
||||||
className="w-full"
|
|
||||||
data={revenueSeries}
|
|
||||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
|
||||||
>
|
|
||||||
<StatCardHoverBridge
|
|
||||||
dataKey="value"
|
|
||||||
formatLabel={formatStatCardMonth}
|
|
||||||
onHoverChange={setHover}
|
|
||||||
/>
|
|
||||||
<LinearGradient
|
|
||||||
from="var(--chart-1)"
|
|
||||||
fromOpacity={0.45}
|
|
||||||
id="stat-card-area-fill"
|
|
||||||
to="var(--chart-1)"
|
|
||||||
toOpacity={0}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
curve={curveCardinal.tension(0.65)}
|
|
||||||
dataKey="value"
|
|
||||||
fill="url(#stat-card-area-fill)"
|
|
||||||
fillOpacity={1}
|
|
||||||
gradientToOpacity={0}
|
|
||||||
showHighlight
|
|
||||||
stroke="var(--chart-1)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</StatCardChart>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface StatCardHoverState {
|
|
||||||
value: number | null;
|
|
||||||
label: string | null;
|
|
||||||
trend: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const statCardValueClassName =
|
|
||||||
"text-3xl font-semibold leading-none tracking-tight";
|
|
||||||
|
|
||||||
export const statCardLabelClassName = "mt-0 text-xs";
|
|
||||||
|
|
||||||
export const statCardChartHeights = {
|
|
||||||
sm: "[--stat-card-chart-h:96px]",
|
|
||||||
md: "[--stat-card-chart-h:190px]",
|
|
||||||
lg: "[--stat-card-chart-h:420px]",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Bleeds charts edge-to-edge inside stat card content padding. */
|
|
||||||
export function StatCardChart({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
size = "sm",
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
size?: keyof typeof statCardChartHeights;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative -mx-4 -mb-3 overflow-hidden",
|
|
||||||
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
|
|
||||||
statCardChartHeights[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useChart } from "@/components/charts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import type { StatCardHoverState } from "./stat-card-chart";
|
|
||||||
|
|
||||||
export type { StatCardHoverState } from "./stat-card-chart";
|
|
||||||
|
|
||||||
export function formatStatCardMonth(date: Date) {
|
|
||||||
return date.toLocaleDateString("en-US", { month: "short" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatStatCardWeekday(date: Date) {
|
|
||||||
return date.toLocaleDateString("en-US", { weekday: "long" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePointDate(raw: unknown): Date | null {
|
|
||||||
if (raw instanceof Date) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
return new Date(raw);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePeriodTrend(
|
|
||||||
data: Record<string, unknown>[],
|
|
||||||
index: number,
|
|
||||||
dataKey: string
|
|
||||||
): number | null {
|
|
||||||
if (index <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = data[index]?.[dataKey];
|
|
||||||
const previous = data[index - 1]?.[dataKey];
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof current !== "number" ||
|
|
||||||
typeof previous !== "number" ||
|
|
||||||
previous === 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ((current - previous) / previous) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Syncs hovered chart values, labels, and trend into stat card UI. */
|
|
||||||
export function StatCardHoverBridge({
|
|
||||||
dataKey,
|
|
||||||
dateKey = "date",
|
|
||||||
formatLabel,
|
|
||||||
onHoverChange,
|
|
||||||
}: {
|
|
||||||
dataKey: string;
|
|
||||||
dateKey?: string;
|
|
||||||
formatLabel: (date: Date) => string;
|
|
||||||
onHoverChange: (state: StatCardHoverState) => void;
|
|
||||||
}) {
|
|
||||||
const { data, tooltipData } = useChart();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tooltipData?.point) {
|
|
||||||
onHoverChange({ value: null, label: null, trend: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = tooltipData.point[dataKey];
|
|
||||||
const value = typeof raw === "number" ? raw : null;
|
|
||||||
const date = parsePointDate(tooltipData.point[dateKey]);
|
|
||||||
const label = date ? formatLabel(date) : null;
|
|
||||||
const trend = computePeriodTrend(data, tooltipData.index, dataKey);
|
|
||||||
|
|
||||||
onHoverChange({ value, label, trend });
|
|
||||||
}, [data, dataKey, dateKey, formatLabel, onHoverChange, tooltipData]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { CentralIcon } from "@central-icons-react/all";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function TrendBadge({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const positive = value >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
positive &&
|
|
||||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
variant={positive ? "outline" : "destructive"}
|
|
||||||
>
|
|
||||||
<CentralIcon
|
|
||||||
className="size-3"
|
|
||||||
data-icon="inline-start"
|
|
||||||
fill="outlined"
|
|
||||||
join="round"
|
|
||||||
name={positive ? "IconArrowUp" : "IconArrowDown"}
|
|
||||||
radius="0"
|
|
||||||
stroke="1.5"
|
|
||||||
/>
|
|
||||||
{positive ? "+" : ""}
|
|
||||||
{value.toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface StatCardHoverState {
|
|
||||||
value: number | null;
|
|
||||||
label: string | null;
|
|
||||||
trend: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const statCardValueClassName =
|
|
||||||
"text-3xl font-semibold leading-none tracking-tight";
|
|
||||||
|
|
||||||
export const statCardLabelClassName = "mt-0 text-xs";
|
|
||||||
|
|
||||||
export const statCardChartHeights = {
|
|
||||||
sm: "[--stat-card-chart-h:96px]",
|
|
||||||
md: "[--stat-card-chart-h:190px]",
|
|
||||||
lg: "[--stat-card-chart-h:420px]",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Bleeds charts edge-to-edge inside stat card content padding. */
|
|
||||||
export function StatCardChart({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
size = "sm",
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
size?: keyof typeof statCardChartHeights;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative -mx-4 -mb-3 overflow-hidden",
|
|
||||||
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
|
|
||||||
statCardChartHeights[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useChoropleth } from "@/components/charts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { computeVisitorTrend, getVisitorValue } from "../data/visitors";
|
|
||||||
import type { StatCardHoverState } from "./stat-card-chart";
|
|
||||||
|
|
||||||
/** Syncs hovered choropleth feature into stat card NumberFlow and trend badge. */
|
|
||||||
export function StatCardChoroplethHoverBridge({
|
|
||||||
onHoverChange,
|
|
||||||
}: {
|
|
||||||
onHoverChange: (state: StatCardHoverState) => void;
|
|
||||||
}) {
|
|
||||||
const { tooltipData } = useChoropleth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tooltipData?.feature) {
|
|
||||||
onHoverChange({ value: null, label: null, trend: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const feature = tooltipData.feature;
|
|
||||||
const label = (feature.properties?.name as string | undefined) ?? "Unknown";
|
|
||||||
const visitors = getVisitorValue(feature);
|
|
||||||
const value = visitors ?? 0;
|
|
||||||
const trend = visitors === undefined ? null : computeVisitorTrend(visitors);
|
|
||||||
|
|
||||||
onHoverChange({ value, label, trend });
|
|
||||||
}, [onHoverChange, tooltipData]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ChoroplethFeature } from "@/components/charts";
|
|
||||||
import {
|
|
||||||
ChartStatFlow,
|
|
||||||
ChoroplethChart,
|
|
||||||
ChoroplethFeatureComponent,
|
|
||||||
ChoroplethTooltip,
|
|
||||||
} from "@/components/charts";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useWorldDataStandalone } from "@/lib/use-world-data";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
getVisitorColor,
|
|
||||||
getVisitorValue,
|
|
||||||
visitorStats,
|
|
||||||
} from "../data/visitors";
|
|
||||||
import {
|
|
||||||
StatCardChart,
|
|
||||||
type StatCardHoverState,
|
|
||||||
statCardLabelClassName,
|
|
||||||
statCardValueClassName,
|
|
||||||
} from "./stat-card-chart";
|
|
||||||
import { StatCardChoroplethHoverBridge } from "./stat-card-choropleth-hover-bridge";
|
|
||||||
import { TrendBadge } from "./trend-badge";
|
|
||||||
|
|
||||||
export function StatCardChoropleth() {
|
|
||||||
const { worldData, isLoading } = useWorldDataStandalone();
|
|
||||||
const [hover, setHover] = useState<StatCardHoverState>({
|
|
||||||
value: null,
|
|
||||||
label: null,
|
|
||||||
trend: null,
|
|
||||||
});
|
|
||||||
const displayValue = hover.value ?? visitorStats.total;
|
|
||||||
const displayLabel = hover.label ?? "Total";
|
|
||||||
const displayTrend = hover.trend ?? visitorStats.trend;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="relative w-full gap-0 overflow-hidden py-0">
|
|
||||||
<CardHeader className="pointer-events-none absolute inset-x-0 top-0 z-10 grid auto-rows-min grid-cols-[1fr_auto] items-start gap-1 border-0 bg-gradient-to-b from-45% from-card to-transparent px-4 py-3 pb-10 shadow-none ring-0">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<CardTitle>Unique Visitors</CardTitle>
|
|
||||||
<ChartStatFlow
|
|
||||||
label={displayLabel}
|
|
||||||
labelClassName={statCardLabelClassName}
|
|
||||||
value={displayValue}
|
|
||||||
valueClassName={statCardValueClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CardAction>
|
|
||||||
<TrendBadge value={displayTrend} />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{isLoading || !worldData ? (
|
|
||||||
<StatCardChart className="mx-0 mb-0 min-h-[420px]" size="lg">
|
|
||||||
<div className="flex h-full min-h-[420px] items-center justify-center text-muted-foreground text-xs">
|
|
||||||
Loading map…
|
|
||||||
</div>
|
|
||||||
</StatCardChart>
|
|
||||||
) : (
|
|
||||||
<StatCardChart className="mx-0 mb-0 min-h-[420px]" size="lg">
|
|
||||||
<ChoroplethChart
|
|
||||||
aspectRatio="2.5 / 1"
|
|
||||||
className="min-h-[420px] w-full"
|
|
||||||
data={worldData}
|
|
||||||
>
|
|
||||||
<StatCardChoroplethHoverBridge onHoverChange={setHover} />
|
|
||||||
<ChoroplethFeatureComponent
|
|
||||||
getFeatureColor={(feature: ChoroplethFeature) =>
|
|
||||||
getVisitorColor(feature)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChoroplethTooltip
|
|
||||||
getFeatureValue={getVisitorValue}
|
|
||||||
valueLabel="Visitors"
|
|
||||||
/>
|
|
||||||
</ChoroplethChart>
|
|
||||||
</StatCardChart>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { CentralIcon } from "@central-icons-react/all";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function TrendBadge({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const positive = value >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
positive &&
|
|
||||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
variant={positive ? "outline" : "destructive"}
|
|
||||||
>
|
|
||||||
<CentralIcon
|
|
||||||
className="size-3"
|
|
||||||
data-icon="inline-start"
|
|
||||||
fill="outlined"
|
|
||||||
join="round"
|
|
||||||
name={positive ? "IconArrowUp" : "IconArrowDown"}
|
|
||||||
radius="0"
|
|
||||||
stroke="1.5"
|
|
||||||
/>
|
|
||||||
{positive ? "+" : ""}
|
|
||||||
{value.toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { FeatureCollection, Geometry } from "geojson";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { feature } from "topojson-client";
|
|
||||||
import type { GeometryCollection, Topology } from "topojson-specification";
|
|
||||||
|
|
||||||
interface CountryProperties {
|
|
||||||
name: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorldTopology extends Topology {
|
|
||||||
objects: {
|
|
||||||
[key: string]: GeometryCollection<CountryProperties>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORLD_DATA_URL =
|
|
||||||
"https://raw.githubusercontent.com/subyfly/topojson/refs/heads/master/world-countries.json";
|
|
||||||
|
|
||||||
// Global cache to avoid refetching across component mounts
|
|
||||||
let globalWorldDataCache: FeatureCollection<
|
|
||||||
Geometry,
|
|
||||||
CountryProperties
|
|
||||||
> | null = null;
|
|
||||||
let globalFetchPromise: Promise<FeatureCollection<
|
|
||||||
Geometry,
|
|
||||||
CountryProperties
|
|
||||||
> | null> | null = null;
|
|
||||||
|
|
||||||
function fetchWorldData(): Promise<FeatureCollection<
|
|
||||||
Geometry,
|
|
||||||
CountryProperties
|
|
||||||
> | null> {
|
|
||||||
// Return cached data if available
|
|
||||||
if (globalWorldDataCache) {
|
|
||||||
return Promise.resolve(globalWorldDataCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return existing promise if fetch is in progress
|
|
||||||
if (globalFetchPromise) {
|
|
||||||
return globalFetchPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new fetch
|
|
||||||
globalFetchPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(WORLD_DATA_URL);
|
|
||||||
const topology = (await response.json()) as WorldTopology;
|
|
||||||
const objectKey = Object.keys(topology.objects)[0];
|
|
||||||
if (!objectKey) {
|
|
||||||
throw new Error("No objects found in topology");
|
|
||||||
}
|
|
||||||
const geoObject = topology.objects[objectKey];
|
|
||||||
if (!geoObject) {
|
|
||||||
throw new Error("Object not found in topology");
|
|
||||||
}
|
|
||||||
const geojson = feature(
|
|
||||||
topology,
|
|
||||||
geoObject
|
|
||||||
) as unknown as FeatureCollection<Geometry, CountryProperties>;
|
|
||||||
globalWorldDataCache = geojson;
|
|
||||||
return geojson;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch world data:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return globalFetchPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorldDataContextValue {
|
|
||||||
worldData: FeatureCollection<Geometry, CountryProperties> | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WorldDataContext = createContext<WorldDataContextValue>({
|
|
||||||
worldData: null,
|
|
||||||
isLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function WorldDataProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [worldData, setWorldData] = useState<FeatureCollection<
|
|
||||||
Geometry,
|
|
||||||
CountryProperties
|
|
||||||
> | null>(globalWorldDataCache);
|
|
||||||
const [isLoading, setIsLoading] = useState(!globalWorldDataCache);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (globalWorldDataCache) {
|
|
||||||
setWorldData(globalWorldDataCache);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchWorldData().then((data) => {
|
|
||||||
setWorldData(data);
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorldDataContext.Provider value={{ worldData, isLoading }}>
|
|
||||||
{children}
|
|
||||||
</WorldDataContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorldData() {
|
|
||||||
return useContext(WorldDataContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standalone hook for components that don't have the provider
|
|
||||||
export function useWorldDataStandalone() {
|
|
||||||
const [worldData, setWorldData] = useState<FeatureCollection<
|
|
||||||
Geometry,
|
|
||||||
CountryProperties
|
|
||||||
> | null>(globalWorldDataCache);
|
|
||||||
const [isLoading, setIsLoading] = useState(!globalWorldDataCache);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (globalWorldDataCache) {
|
|
||||||
setWorldData(globalWorldDataCache);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchWorldData().then((data) => {
|
|
||||||
setWorldData(data);
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { worldData, isLoading };
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface StatCardHoverState {
|
|
||||||
value: number | null;
|
|
||||||
label: string | null;
|
|
||||||
trend: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const statCardValueClassName =
|
|
||||||
"text-3xl font-semibold leading-none tracking-tight";
|
|
||||||
|
|
||||||
export const statCardLabelClassName = "mt-0 text-xs";
|
|
||||||
|
|
||||||
export const statCardChartHeights = {
|
|
||||||
sm: "[--stat-card-chart-h:96px]",
|
|
||||||
md: "[--stat-card-chart-h:190px]",
|
|
||||||
lg: "[--stat-card-chart-h:420px]",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Bleeds charts edge-to-edge inside stat card content padding. */
|
|
||||||
export function StatCardChart({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
size = "sm",
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
size?: keyof typeof statCardChartHeights;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative -mx-4 -mb-3 overflow-hidden",
|
|
||||||
"[&_.relative.w-full]:aspect-auto! [&_.relative.w-full]:h-[var(--stat-card-chart-h)]!",
|
|
||||||
statCardChartHeights[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useChart } from "@/components/charts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import type { StatCardHoverState } from "./stat-card-chart";
|
|
||||||
|
|
||||||
export type { StatCardHoverState } from "./stat-card-chart";
|
|
||||||
|
|
||||||
export function formatStatCardMonth(date: Date) {
|
|
||||||
return date.toLocaleDateString("en-US", { month: "short" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatStatCardWeekday(date: Date) {
|
|
||||||
return date.toLocaleDateString("en-US", { weekday: "long" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePointDate(raw: unknown): Date | null {
|
|
||||||
if (raw instanceof Date) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
return new Date(raw);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePeriodTrend(
|
|
||||||
data: Record<string, unknown>[],
|
|
||||||
index: number,
|
|
||||||
dataKey: string
|
|
||||||
): number | null {
|
|
||||||
if (index <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = data[index]?.[dataKey];
|
|
||||||
const previous = data[index - 1]?.[dataKey];
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof current !== "number" ||
|
|
||||||
typeof previous !== "number" ||
|
|
||||||
previous === 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ((current - previous) / previous) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Syncs hovered chart values, labels, and trend into stat card UI. */
|
|
||||||
export function StatCardHoverBridge({
|
|
||||||
dataKey,
|
|
||||||
dateKey = "date",
|
|
||||||
formatLabel,
|
|
||||||
onHoverChange,
|
|
||||||
}: {
|
|
||||||
dataKey: string;
|
|
||||||
dateKey?: string;
|
|
||||||
formatLabel: (date: Date) => string;
|
|
||||||
onHoverChange: (state: StatCardHoverState) => void;
|
|
||||||
}) {
|
|
||||||
const { data, tooltipData } = useChart();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tooltipData?.point) {
|
|
||||||
onHoverChange({ value: null, label: null, trend: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = tooltipData.point[dataKey];
|
|
||||||
const value = typeof raw === "number" ? raw : null;
|
|
||||||
const date = parsePointDate(tooltipData.point[dateKey]);
|
|
||||||
const label = date ? formatLabel(date) : null;
|
|
||||||
const trend = computePeriodTrend(data, tooltipData.index, dataKey);
|
|
||||||
|
|
||||||
onHoverChange({ value, label, trend });
|
|
||||||
}, [data, dataKey, dateKey, formatLabel, onHoverChange, tooltipData]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ChartStatFlow, Line, LineChart } from "@/components/charts";
|
|
||||||
import { curveBasis } from "@visx/curve";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { sessionsSeries, sessionsStats } from "../data/sessions-series";
|
|
||||||
import {
|
|
||||||
StatCardChart,
|
|
||||||
statCardLabelClassName,
|
|
||||||
statCardValueClassName,
|
|
||||||
} from "./stat-card-chart";
|
|
||||||
import {
|
|
||||||
formatStatCardWeekday,
|
|
||||||
StatCardHoverBridge,
|
|
||||||
type StatCardHoverState,
|
|
||||||
} from "./stat-card-hover-bridge";
|
|
||||||
import { TrendBadge } from "./trend-badge";
|
|
||||||
|
|
||||||
export function StatCardLine() {
|
|
||||||
const [hover, setHover] = useState<StatCardHoverState>({
|
|
||||||
value: null,
|
|
||||||
label: null,
|
|
||||||
trend: null,
|
|
||||||
});
|
|
||||||
const average = Math.round(sessionsStats.average);
|
|
||||||
const displayValue = hover.value === null ? average : Math.round(hover.value);
|
|
||||||
const displayLabel = hover.label ?? "Avg";
|
|
||||||
const displayTrend = hover.trend ?? sessionsStats.trend;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="w-full gap-0 py-0">
|
|
||||||
<CardHeader className="px-4 py-3">
|
|
||||||
<CardTitle>Active Sessions</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
<TrendBadge value={displayTrend} />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-4 pt-2 pb-3">
|
|
||||||
<StatCardChart size="md">
|
|
||||||
<div className="pointer-events-none absolute right-4 bottom-4 z-10 flex flex-col items-end text-right">
|
|
||||||
<ChartStatFlow
|
|
||||||
label={displayLabel}
|
|
||||||
labelClassName={statCardLabelClassName}
|
|
||||||
value={displayValue}
|
|
||||||
valueClassName={statCardValueClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LineChart
|
|
||||||
aspectRatio="2.5 / 1"
|
|
||||||
className="w-full"
|
|
||||||
data={sessionsSeries}
|
|
||||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
|
||||||
>
|
|
||||||
<StatCardHoverBridge
|
|
||||||
dataKey="value"
|
|
||||||
formatLabel={formatStatCardWeekday}
|
|
||||||
onHoverChange={setHover}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
curve={curveBasis}
|
|
||||||
dataKey="value"
|
|
||||||
showHighlight
|
|
||||||
stroke="var(--chart-3)"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</StatCardChart>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { CentralIcon } from "@central-icons-react/all";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function TrendBadge({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const positive = value >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
positive &&
|
|
||||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
variant={positive ? "outline" : "destructive"}
|
|
||||||
>
|
|
||||||
<CentralIcon
|
|
||||||
className="size-3"
|
|
||||||
data-icon="inline-start"
|
|
||||||
fill="outlined"
|
|
||||||
join="round"
|
|
||||||
name={positive ? "IconArrowUp" : "IconArrowDown"}
|
|
||||||
radius="0"
|
|
||||||
stroke="1.5"
|
|
||||||
/>
|
|
||||||
{positive ? "+" : ""}
|
|
||||||
{value.toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { AreaChart } from "./area-chart";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { AreaChart, Area, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { AreaChart, Area, Grid, XAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
import { curveNatural } from "@visx/curve";
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), desktop: 186 },
|
|
||||||
{ date: new Date("2024-02-01"), desktop: 305 },
|
|
||||||
{ date: new Date("2024-03-01"), desktop: 237 },
|
|
||||||
{ date: new Date("2024-04-01"), desktop: 73 },
|
|
||||||
{ date: new Date("2024-05-01"), desktop: 209 },
|
|
||||||
{ date: new Date("2024-06-01"), desktop: 214 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<AreaChart data={chartData} animationDuration={1100}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Area dataKey="desktop" curve={curveNatural} strokeWidth={2.5} fillOpacity={0.4} />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</AreaChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { BarChart } from "./bar-chart";
|
|
||||||
export { Bar } from "./bar";
|
|
||||||
export { BarXAxis } from "./bar-x-axis";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { BarChart, Bar, BarXAxis, Grid, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { BarChart, Bar, BarXAxis, Grid, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ month: "Jan", revenue: 12000, profit: 4500 },
|
|
||||||
{ month: "Feb", revenue: 15500, profit: 5200 },
|
|
||||||
{ month: "Mar", revenue: 11000, profit: 3800 },
|
|
||||||
{ month: "Apr", revenue: 18500, profit: 7100 },
|
|
||||||
{ month: "May", revenue: 16800, profit: 5400 },
|
|
||||||
{ month: "Jun", revenue: 21200, profit: 8800 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<BarChart data={data} xDataKey="month">
|
|
||||||
<Grid horizontal />
|
|
||||||
<Bar dataKey="revenue" fill="var(--chart-line-primary)" lineCap="round" />
|
|
||||||
<Bar dataKey="profit" fill="var(--chart-line-secondary)" lineCap="round" />
|
|
||||||
<BarXAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</BarChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { CandlestickChart } from "./candlestick-chart";
|
|
||||||
export { Candlestick } from "./candlestick";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { YAxis } from "./y-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
const ohlcData = [
|
|
||||||
{ date: new Date("2024-01-02"), open: 100, high: 108, low: 98, close: 105 },
|
|
||||||
{ date: new Date("2024-01-03"), open: 105, high: 110, low: 102, close: 103 },
|
|
||||||
{ date: new Date("2024-01-04"), open: 103, high: 112, low: 101, close: 110 },
|
|
||||||
{ date: new Date("2024-01-05"), open: 110, high: 115, low: 107, close: 108 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<CandlestickChart data={ohlcData}>
|
|
||||||
<Grid horizontal vertical />
|
|
||||||
<Candlestick />
|
|
||||||
<XAxis />
|
|
||||||
<YAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</CandlestickChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "./choropleth";
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{ id: "US", name: "United States", value: 120 },
|
|
||||||
{ id: "CA", name: "Canada", value: 45 },
|
|
||||||
{ id: "GB", name: "United Kingdom", value: 62 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<ChoroplethChart data={features} aspectRatio="2 / 1">
|
|
||||||
<ChoroplethFeatureComponent />
|
|
||||||
<ChoroplethTooltip />
|
|
||||||
</ChoroplethChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { ComposedChart } from "./composed-chart";
|
|
||||||
export { SeriesBar } from "./series-bar";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
import { curveNatural } from "@visx/curve";
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), revenue: 4200, runRate: 3800 },
|
|
||||||
{ date: new Date("2024-02-01"), revenue: 5100, runRate: 4600 },
|
|
||||||
{ date: new Date("2024-03-01"), revenue: 4800, runRate: 5200 },
|
|
||||||
{ date: new Date("2024-04-01"), revenue: 5500, runRate: 5000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<ComposedChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<SeriesBar dataKey="revenue" fill="var(--chart-1)" />
|
|
||||||
<Area dataKey="runRate" curve={curveNatural} fill="var(--chart-4)" fillOpacity={0.35} />
|
|
||||||
<Line dataKey="runRate" curve={curveNatural} stroke="var(--chart-2)" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</ComposedChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { FunnelChart } from "./funnel-chart";
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { FunnelChart } from "@bklitui/ui/charts"
|
|
||||||
import { FunnelChart } from "@/components/charts"
|
|
||||||
|
|
||||||
const funnelData = [
|
|
||||||
{ label: "Visitors", value: 12000 },
|
|
||||||
{ label: "Signups", value: 4800 },
|
|
||||||
{ label: "Activated", value: 2100 },
|
|
||||||
{ label: "Paid", value: 840 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<FunnelChart data={funnelData} aspectRatio="2 / 1" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { Gauge } from "./gauge";
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { Gauge } from "@bklitui/ui/charts"
|
|
||||||
import { Gauge } from "@/components/charts"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<Gauge
|
|
||||||
value={72}
|
|
||||||
centerValue={72}
|
|
||||||
totalNotches={40}
|
|
||||||
defaultLabel="Score"
|
|
||||||
formatOptions={{ style: "percent" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export {
|
|
||||||
HeatmapCells,
|
|
||||||
HeatmapChart,
|
|
||||||
HeatmapInteractionBoundary,
|
|
||||||
HeatmapInteractionProvider,
|
|
||||||
HeatmapLegend,
|
|
||||||
HeatmapTooltip,
|
|
||||||
HeatmapXAxis,
|
|
||||||
HeatmapYAxis,
|
|
||||||
} from "./heatmap";
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis } from "@bklitui/ui/charts"
|
|
||||||
import { HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis } from "@/components/charts"
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{
|
|
||||||
bin: 0,
|
|
||||||
bins: [
|
|
||||||
{ bin: 0, count: 2, date: new Date(2024, 0, 1) },
|
|
||||||
{ bin: 1, count: 0, date: new Date(2024, 0, 2) },
|
|
||||||
{ bin: 2, count: 3, date: new Date(2024, 0, 3) },
|
|
||||||
{ bin: 3, count: 1, date: new Date(2024, 0, 4) },
|
|
||||||
{ bin: 4, count: 4, date: new Date(2024, 0, 5) },
|
|
||||||
{ bin: 5, count: 0, date: new Date(2024, 0, 6) },
|
|
||||||
{ bin: 6, count: 1, date: new Date(2024, 0, 7) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bin: 1,
|
|
||||||
bins: [
|
|
||||||
{ bin: 0, count: 1, date: new Date(2024, 0, 8) },
|
|
||||||
{ bin: 1, count: 2, date: new Date(2024, 0, 9) },
|
|
||||||
{ bin: 2, count: 0, date: new Date(2024, 0, 10) },
|
|
||||||
{ bin: 3, count: 3, date: new Date(2024, 0, 11) },
|
|
||||||
{ bin: 4, count: 2, date: new Date(2024, 0, 12) },
|
|
||||||
{ bin: 5, count: 1, date: new Date(2024, 0, 13) },
|
|
||||||
{ bin: 6, count: 0, date: new Date(2024, 0, 14) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<HeatmapInteractionProvider>
|
|
||||||
<HeatmapInteractionBoundary>
|
|
||||||
<div className="flex w-full flex-col items-stretch gap-3">
|
|
||||||
<HeatmapChart className="w-full" data={data} layout="fluid">
|
|
||||||
<HeatmapCells />
|
|
||||||
<HeatmapXAxis />
|
|
||||||
<HeatmapYAxis />
|
|
||||||
<HeatmapTooltip />
|
|
||||||
</HeatmapChart>
|
|
||||||
<HeatmapLegend />
|
|
||||||
</div>
|
|
||||||
</HeatmapInteractionBoundary>
|
|
||||||
</HeatmapInteractionProvider>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { LineChart } from "./line-chart";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
import { curveNatural } from "@visx/curve";
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), users: 1200 },
|
|
||||||
{ date: new Date("2024-02-01"), users: 1350 },
|
|
||||||
{ date: new Date("2024-03-01"), users: 1100 },
|
|
||||||
{ date: new Date("2024-04-01"), users: 1450 },
|
|
||||||
{ date: new Date("2024-05-01"), users: 1380 },
|
|
||||||
{ date: new Date("2024-06-01"), users: 1520 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Line dataKey="users" curve={curveNatural} stroke="var(--chart-line-primary)" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</LineChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { LiveLineChart } from "./live-line-chart";
|
|
||||||
export { LiveLine } from "./live-line";
|
|
||||||
export { LiveXAxis } from "./live-x-axis";
|
|
||||||
export { LiveYAxis } from "./live-y-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
import { curveNatural } from "@visx/curve";
|
|
||||||
|
|
||||||
const initialData = Array.from({ length: 24 }, (_, i) => ({
|
|
||||||
time: Date.now() - (23 - i) * 60_000,
|
|
||||||
value: 50 + Math.sin(i / 3) * 20,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<LiveLineChart
|
|
||||||
data={initialData}
|
|
||||||
interval={1000}
|
|
||||||
maxPoints={24}
|
|
||||||
xDataKey="time"
|
|
||||||
yDataKey="value"
|
|
||||||
>
|
|
||||||
<LiveLine curve={curveNatural} stroke="var(--chart-line-primary)" />
|
|
||||||
<LiveXAxis />
|
|
||||||
<LiveYAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</LiveLineChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { PieChart } from "./pie-chart";
|
|
||||||
export { PieSlice } from "./pie-slice";
|
|
||||||
export { PieCenter } from "./pie-center";
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { PieChart, PieSlice, PieCenter } from "@bklitui/ui/charts"
|
|
||||||
import { PieChart, PieSlice, PieCenter } from "@/components/charts"
|
|
||||||
|
|
||||||
const pieData = [
|
|
||||||
{ label: "Direct", value: 320 },
|
|
||||||
{ label: "Organic", value: 280 },
|
|
||||||
{ label: "Referral", value: 190 },
|
|
||||||
{ label: "Social", value: 140 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<PieChart data={pieData} size={280}>
|
|
||||||
{pieData.map((item, i) => (
|
|
||||||
<PieSlice index={i} key={item.label} />
|
|
||||||
))}
|
|
||||||
<PieCenter defaultLabel="Traffic" />
|
|
||||||
</PieChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { LineChart } from "./line-chart";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
export { ProfitLossLine } from "./profit-loss-line";
|
|
||||||
export { ProfitLossLegend } from "./profit-loss-legend";
|
|
||||||
export { ProfitLossLegendHoverProvider } from "./profit-loss-legend-hover";
|
|
||||||
export {
|
|
||||||
profitLossColor,
|
|
||||||
resolveProfitLossTooltipLabel,
|
|
||||||
} from "./profit-loss-line";
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import from "@bklitui/ui/charts"
|
|
||||||
import {
|
|
||||||
ChartTooltip,
|
|
||||||
Grid,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ProfitLossLegend,
|
|
||||||
ProfitLossLegendHoverProvider,
|
|
||||||
ProfitLossLine,
|
|
||||||
profitLossColor,
|
|
||||||
resolveProfitLossTooltipLabel,
|
|
||||||
XAxis,
|
|
||||||
} from "@/components/charts"
|
|
||||||
import { curveLinear } from "@visx/curve";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), pnl: 420 },
|
|
||||||
{ date: new Date("2024-01-05"), pnl: 180 },
|
|
||||||
{ date: new Date("2024-01-10"), pnl: -240 },
|
|
||||||
{ date: new Date("2024-01-15"), pnl: -90 },
|
|
||||||
{ date: new Date("2024-01-20"), pnl: 310 },
|
|
||||||
{ date: new Date("2024-01-25"), pnl: 520 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
const [legendHoveredIndex, setLegendHoveredIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="flex w-full max-w-3xl flex-col gap-2">
|
|
||||||
<ProfitLossLegend
|
|
||||||
align="center"
|
|
||||||
hoveredIndex={legendHoveredIndex}
|
|
||||||
onHoverChange={setLegendHoveredIndex}
|
|
||||||
/>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<Grid highlightRowValues={[0]} horizontal />
|
|
||||||
<Line
|
|
||||||
curve={curveLinear}
|
|
||||||
dataKey="pnl"
|
|
||||||
fadeEdges={false}
|
|
||||||
showHighlight={false}
|
|
||||||
stroke="transparent"
|
|
||||||
strokeWidth={0}
|
|
||||||
/>
|
|
||||||
<ProfitLossLegendHoverProvider hoveredIndex={legendHoveredIndex}>
|
|
||||||
<ProfitLossLine dataKey="pnl" />
|
|
||||||
</ProfitLossLegendHoverProvider>
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip
|
|
||||||
indicatorColor={(point) => profitLossColor((point.pnl as number) ?? 0)}
|
|
||||||
rows={(point) => {
|
|
||||||
const value = (point.pnl as number) ?? 0;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
color: profitLossColor(value),
|
|
||||||
label: resolveProfitLossTooltipLabel(""),
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { RadarChart } from "./radar-chart";
|
|
||||||
export { RadarGrid } from "./radar-grid";
|
|
||||||
export { RadarAxis } from "./radar-axis";
|
|
||||||
export { RadarLabels } from "./radar-labels";
|
|
||||||
export { RadarArea } from "./radar-area";
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea } from "@bklitui/ui/charts"
|
|
||||||
import { RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea } from "@/components/charts"
|
|
||||||
|
|
||||||
const metrics = [
|
|
||||||
{ key: "speed", label: "Speed" },
|
|
||||||
{ key: "reliability", label: "Reliability" },
|
|
||||||
{ key: "comfort", label: "Comfort" },
|
|
||||||
{ key: "safety", label: "Safety" },
|
|
||||||
{ key: "efficiency", label: "Efficiency" },
|
|
||||||
];
|
|
||||||
const data = [
|
|
||||||
{ id: "a", speed: 80, reliability: 70, comfort: 60, safety: 90, efficiency: 75 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<RadarChart data={data} metrics={metrics} size={320}>
|
|
||||||
<RadarGrid />
|
|
||||||
<RadarAxis />
|
|
||||||
<RadarLabels fontSize={10} offset={16} />
|
|
||||||
{data.map((row, i) => (
|
|
||||||
<RadarArea key={row.id} index={i} fill="var(--chart-line-primary)" fillOpacity={0.35} />
|
|
||||||
))}
|
|
||||||
</RadarChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { RingChart } from "./ring-chart";
|
|
||||||
export { Ring } from "./ring";
|
|
||||||
export { RingCenter } from "./ring-center";
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { RingChart, Ring, RingCenter } from "@bklitui/ui/charts"
|
|
||||||
import { RingChart, Ring, RingCenter } from "@/components/charts"
|
|
||||||
|
|
||||||
const ringData = [
|
|
||||||
{ label: "Email", value: 42 },
|
|
||||||
{ label: "Social", value: 28 },
|
|
||||||
{ label: "Direct", value: 18 },
|
|
||||||
{ label: "Other", value: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<RingChart data={ringData} size={280} strokeWidth={14}>
|
|
||||||
{ringData.map((item, i) => (
|
|
||||||
<Ring index={i} key={item.label} />
|
|
||||||
))}
|
|
||||||
<RingCenter defaultLabel="Channels" />
|
|
||||||
</RingChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "./sankey";
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
nodes: [
|
|
||||||
{ name: "Ads" },
|
|
||||||
{ name: "Organic" },
|
|
||||||
{ name: "Landing" },
|
|
||||||
{ name: "Product" },
|
|
||||||
{ name: "Checkout" },
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{ source: 0, target: 2, value: 40 },
|
|
||||||
{ source: 1, target: 2, value: 30 },
|
|
||||||
{ source: 2, target: 3, value: 50 },
|
|
||||||
{ source: 3, target: 4, value: 35 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<SankeyChart data={data} aspectRatio="16 / 9">
|
|
||||||
<SankeyLink />
|
|
||||||
<SankeyNode />
|
|
||||||
<SankeyTooltip />
|
|
||||||
</SankeyChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
|
|
||||||
export { ScatterChart } from "./scatter-chart";
|
|
||||||
export { Scatter } from "./scatter";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { ScatterChart, Scatter, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"
|
|
||||||
import { ScatterChart, Scatter, Grid, XAxis, ChartTooltip } from "@/components/charts"
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), sessions: 420, conversions: 28 },
|
|
||||||
{ date: new Date("2024-02-01"), sessions: 510, conversions: 34 },
|
|
||||||
{ date: new Date("2024-03-01"), sessions: 390, conversions: 22 },
|
|
||||||
{ date: new Date("2024-04-01"), sessions: 580, conversions: 41 },
|
|
||||||
{ date: new Date("2024-05-01"), sessions: 620, conversions: 38 },
|
|
||||||
{ date: new Date("2024-06-01"), sessions: 710, conversions: 52 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<ScatterChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Scatter dataKey="sessions" />
|
|
||||||
<Scatter dataKey="conversions" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</ScatterChart>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
|
|
||||||
export { AreaChart } from "./area-chart";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";
|
|
||||||
export { useChart } from "./chart-context";
|
|
||||||
export { LinearGradient } from "@visx/gradient";
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { StatCardArea } from "@/components/stat-card-area";
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<StatCardArea />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
|
|
||||||
export { type ChoroplethFeature, ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip, useChoropleth } from "./choropleth";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { StatCardChoropleth } from "@/components/stat-card-choropleth";
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
<StatCardChoropleth />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */
|
|
||||||
export { LineChart } from "./line-chart";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";
|
|
||||||
export { useChart } from "./chart-context";
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { StatCardLine } from "@/components/stat-card-line";
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<StatCardLine />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
/**
|
|
||||||
* Syncs stat-card blocks from apps/web/blocks into registry sources and registry.json
|
|
||||||
* (shadcn add + Open in v0). Run from packages/ui after generate-v0-examples.mjs.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
cpSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const uiRoot = join(__dirname, "..");
|
|
||||||
const webBlocksRoot = join(uiRoot, "../../apps/web/blocks");
|
|
||||||
const registryBlocksRoot = join(uiRoot, "registry/blocks");
|
|
||||||
const examplesDir = join(uiRoot, "registry/examples");
|
|
||||||
const registryPath = join(uiRoot, "registry.json");
|
|
||||||
|
|
||||||
const TREND_BADGE_REGISTRY = `"use client";
|
|
||||||
|
|
||||||
import { CentralIcon } from "@central-icons-react/all";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function TrendBadge({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const positive = value >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
positive &&
|
|
||||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
variant={positive ? "outline" : "destructive"}
|
|
||||||
>
|
|
||||||
<CentralIcon
|
|
||||||
className="size-3"
|
|
||||||
data-icon="inline-start"
|
|
||||||
fill="outlined"
|
|
||||||
join="round"
|
|
||||||
name={positive ? "IconArrowUp" : "IconArrowDown"}
|
|
||||||
radius="0"
|
|
||||||
stroke="1.5"
|
|
||||||
/>
|
|
||||||
{positive ? "+" : ""}
|
|
||||||
{value.toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const BLOCK_CHART_INDEX = {
|
|
||||||
"stat-card-area-01": `export { AreaChart } from "./area-chart";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";
|
|
||||||
export { useChart } from "./chart-context";
|
|
||||||
export { LinearGradient } from "@visx/gradient";`,
|
|
||||||
"stat-card-line-01": `export { LineChart } from "./line-chart";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";
|
|
||||||
export { useChart } from "./chart-context";`,
|
|
||||||
"stat-card-choropleth-01": `export { type ChoroplethFeature, ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip, useChoropleth } from "./choropleth";
|
|
||||||
export { ChartStatFlow } from "./chart-stat-flow";`,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {Record<string, { title: string, description: string, previewMaxWidth: string, registryDependencies: string[], dependencies: string[], files: string[], extraFiles?: { path: string, target: string, content?: string }[] }>} */
|
|
||||||
const BLOCKS = {
|
|
||||||
"stat-card-area-01": {
|
|
||||||
title: "Stat Card Area",
|
|
||||||
description:
|
|
||||||
"Revenue stat card with gradient area sparkline, NumberFlow, and trend badge",
|
|
||||||
previewMaxWidth: "max-w-md",
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/area-chart",
|
|
||||||
"@bklit/chart-stat-flow",
|
|
||||||
"card",
|
|
||||||
"badge",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@visx/gradient@4.0.1-alpha.0",
|
|
||||||
"@number-flow/react",
|
|
||||||
"@central-icons-react/all",
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
"components/stat-card-area.tsx",
|
|
||||||
"components/stat-card-chart.tsx",
|
|
||||||
"components/stat-card-hover-bridge.tsx",
|
|
||||||
"components/trend-badge.tsx",
|
|
||||||
"data/revenue-series.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"stat-card-line-01": {
|
|
||||||
title: "Stat Card Line",
|
|
||||||
description:
|
|
||||||
"Sessions stat card with line sparkline, overlaid NumberFlow, and trend badge",
|
|
||||||
previewMaxWidth: "max-w-md",
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/line-chart",
|
|
||||||
"@bklit/chart-stat-flow",
|
|
||||||
"card",
|
|
||||||
"badge",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@number-flow/react",
|
|
||||||
"@central-icons-react/all",
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
"components/stat-card-line.tsx",
|
|
||||||
"components/stat-card-chart.tsx",
|
|
||||||
"components/stat-card-hover-bridge.tsx",
|
|
||||||
"components/trend-badge.tsx",
|
|
||||||
"data/sessions-series.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"stat-card-choropleth-01": {
|
|
||||||
title: "Stat Card Choropleth",
|
|
||||||
description:
|
|
||||||
"Visitor map stat card with choropleth sparkline, NumberFlow, and trend badge",
|
|
||||||
previewMaxWidth: "max-w-3xl",
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/choropleth-chart",
|
|
||||||
"@bklit/chart-stat-flow",
|
|
||||||
"card",
|
|
||||||
"badge",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@number-flow/react",
|
|
||||||
"@types/geojson",
|
|
||||||
"@types/topojson-specification",
|
|
||||||
"@central-icons-react/all",
|
|
||||||
"topojson-client",
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
"components/stat-card-choropleth.tsx",
|
|
||||||
"components/stat-card-choropleth-hover-bridge.tsx",
|
|
||||||
"components/stat-card-chart.tsx",
|
|
||||||
"components/trend-badge.tsx",
|
|
||||||
"data/visitors.ts",
|
|
||||||
"lib/use-world-data.tsx",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMPONENT_EXPORTS = {
|
|
||||||
"stat-card-area-01": "StatCardArea",
|
|
||||||
"stat-card-line-01": "StatCardLine",
|
|
||||||
"stat-card-choropleth-01": "StatCardChoropleth",
|
|
||||||
};
|
|
||||||
|
|
||||||
function transformForRegistry(content, filePath) {
|
|
||||||
if (filePath.endsWith("trend-badge.tsx")) {
|
|
||||||
return TREND_BADGE_REGISTRY;
|
|
||||||
}
|
|
||||||
|
|
||||||
return content
|
|
||||||
.replaceAll("@bklitui/ui/charts", "@/components/charts")
|
|
||||||
.replaceAll("@/components/docs/use-world-data", "@/lib/use-world-data");
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyBlockFile(blockId, relativePath) {
|
|
||||||
const sourcePath = join(webBlocksRoot, blockId, "files", relativePath);
|
|
||||||
const targetPath = join(registryBlocksRoot, blockId, relativePath);
|
|
||||||
|
|
||||||
if (relativePath === "lib/use-world-data.tsx") {
|
|
||||||
const sharedSource = join(
|
|
||||||
uiRoot,
|
|
||||||
"../../apps/web/components/docs/use-world-data.tsx"
|
|
||||||
);
|
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
|
||||||
cpSync(sharedSource, targetPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
throw new Error(`Block source not found: ${sourcePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
|
||||||
const content = transformForRegistry(
|
|
||||||
readFileSync(sourcePath, "utf8"),
|
|
||||||
relativePath
|
|
||||||
);
|
|
||||||
writeFileSync(targetPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockRegistryFiles(blockId, relativePaths) {
|
|
||||||
return relativePaths.map((relativePath) => ({
|
|
||||||
path: `registry/blocks/${blockId}/${relativePath}`,
|
|
||||||
type: relativePath.endsWith(".ts") ? "registry:lib" : "registry:component",
|
|
||||||
target: relativePath,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockChartIndexFile(blockId) {
|
|
||||||
if (!BLOCK_CHART_INDEX[blockId]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: `registry/examples/${blockId}-index.ts`,
|
|
||||||
type: "registry:lib",
|
|
||||||
target: "components/charts/index.ts",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartStatFlowItem = {
|
|
||||||
name: "chart-stat-flow",
|
|
||||||
type: "registry:component",
|
|
||||||
title: "Chart Stat Flow",
|
|
||||||
description: "Animated stat value and label for chart overlays",
|
|
||||||
registryDependencies: ["@bklit/utils"],
|
|
||||||
dependencies: ["@number-flow/react", "motion"],
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: "src/charts/chart-stat-flow.tsx",
|
|
||||||
type: "registry:component",
|
|
||||||
target: "components/charts/chart-stat-flow.tsx",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
mkdirSync(examplesDir, { recursive: true });
|
|
||||||
mkdirSync(registryBlocksRoot, { recursive: true });
|
|
||||||
|
|
||||||
for (const [blockId, spec] of Object.entries(BLOCKS)) {
|
|
||||||
for (const relativePath of spec.files) {
|
|
||||||
copyBlockFile(blockId, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportName = COMPONENT_EXPORTS[blockId];
|
|
||||||
const mainComponentPath = spec.files.find((file) =>
|
|
||||||
file.startsWith("components/stat-card-")
|
|
||||||
);
|
|
||||||
const importPath = `@/components/${mainComponentPath
|
|
||||||
?.replace("components/", "")
|
|
||||||
.replace(".tsx", "")}`;
|
|
||||||
|
|
||||||
const exampleContent = `"use client";
|
|
||||||
|
|
||||||
import { ${exportName} } from "${importPath}";
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full ${spec.previewMaxWidth}">
|
|
||||||
<${exportName} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
writeFileSync(join(examplesDir, `${blockId}.tsx`), exampleContent);
|
|
||||||
|
|
||||||
const chartIndex = BLOCK_CHART_INDEX[blockId];
|
|
||||||
if (chartIndex) {
|
|
||||||
writeFileSync(
|
|
||||||
join(examplesDir, `${blockId}-index.ts`),
|
|
||||||
`/** biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install */\n${chartIndex}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
||||||
|
|
||||||
registry.items = registry.items.filter(
|
|
||||||
(item) =>
|
|
||||||
item.name !== "chart-stat-flow" && !item.name.startsWith("stat-card-")
|
|
||||||
);
|
|
||||||
|
|
||||||
registry.items.push(chartStatFlowItem);
|
|
||||||
|
|
||||||
for (const [blockId, spec] of Object.entries(BLOCKS)) {
|
|
||||||
const blockFiles = blockRegistryFiles(blockId, spec.files);
|
|
||||||
const chartIndexFile = blockChartIndexFile(blockId);
|
|
||||||
if (chartIndexFile) {
|
|
||||||
blockFiles.push(chartIndexFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.items.push({
|
|
||||||
name: blockId,
|
|
||||||
type: "registry:block",
|
|
||||||
title: spec.title,
|
|
||||||
description: spec.description,
|
|
||||||
registryDependencies: spec.registryDependencies,
|
|
||||||
dependencies: spec.dependencies,
|
|
||||||
files: blockFiles,
|
|
||||||
});
|
|
||||||
|
|
||||||
const exampleFiles = [
|
|
||||||
{
|
|
||||||
path: `registry/examples/${blockId}.tsx`,
|
|
||||||
type: "registry:page",
|
|
||||||
target: "app/page.tsx",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (chartIndexFile) {
|
|
||||||
exampleFiles.push(chartIndexFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.items.push({
|
|
||||||
name: `${blockId}-example`,
|
|
||||||
type: "registry:example",
|
|
||||||
title: `${spec.title} Example`,
|
|
||||||
description: `${spec.description} — demo for Open in v0`,
|
|
||||||
registryDependencies: [`@bklit/${blockId}`],
|
|
||||||
dependencies: spec.dependencies,
|
|
||||||
files: exampleFiles,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
|
||||||
console.log(
|
|
||||||
`Synced ${Object.keys(BLOCKS).length} stat-card blocks into registry (${Object.keys(BLOCKS).length * 2 + 1} items).`
|
|
||||||
);
|
|
||||||
|
|
@ -1,520 +0,0 @@
|
||||||
/**
|
|
||||||
* Generates registry example entries for Open in v0 (registry:example + registry:page).
|
|
||||||
* Run from packages/ui: node scripts/generate-v0-examples.mjs
|
|
||||||
*/
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const root = join(__dirname, "..");
|
|
||||||
const examplesDir = join(root, "registry/examples");
|
|
||||||
|
|
||||||
/** Per-chart barrel for installed @/components/charts/* paths in v0. */
|
|
||||||
const CHART_INDEX_EXPORTS = {
|
|
||||||
"area-chart": `export { AreaChart } from "./area-chart";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"bar-chart": `export { BarChart } from "./bar-chart";
|
|
||||||
export { Bar } from "./bar";
|
|
||||||
export { BarXAxis } from "./bar-x-axis";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"line-chart": `export { LineChart } from "./line-chart";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"scatter-chart": `export { ScatterChart } from "./scatter-chart";
|
|
||||||
export { Scatter } from "./scatter";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"pie-chart": `export { PieChart } from "./pie-chart";
|
|
||||||
export { PieSlice } from "./pie-slice";
|
|
||||||
export { PieCenter } from "./pie-center";`,
|
|
||||||
"gauge-chart": `export { Gauge } from "./gauge";`,
|
|
||||||
"heatmap-chart": `export {
|
|
||||||
HeatmapCells,
|
|
||||||
HeatmapChart,
|
|
||||||
HeatmapInteractionBoundary,
|
|
||||||
HeatmapInteractionProvider,
|
|
||||||
HeatmapLegend,
|
|
||||||
HeatmapTooltip,
|
|
||||||
HeatmapXAxis,
|
|
||||||
HeatmapYAxis,
|
|
||||||
} from "./heatmap";`,
|
|
||||||
"ring-chart": `export { RingChart } from "./ring-chart";
|
|
||||||
export { Ring } from "./ring";
|
|
||||||
export { RingCenter } from "./ring-center";`,
|
|
||||||
"radar-chart": `export { RadarChart } from "./radar-chart";
|
|
||||||
export { RadarGrid } from "./radar-grid";
|
|
||||||
export { RadarAxis } from "./radar-axis";
|
|
||||||
export { RadarLabels } from "./radar-labels";
|
|
||||||
export { RadarArea } from "./radar-area";`,
|
|
||||||
"composed-chart": `export { ComposedChart } from "./composed-chart";
|
|
||||||
export { SeriesBar } from "./series-bar";
|
|
||||||
export { Area } from "./area";
|
|
||||||
export { Line } from "./line";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"funnel-chart": `export { FunnelChart } from "./funnel-chart";`,
|
|
||||||
"sankey-chart": `export { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "./sankey";`,
|
|
||||||
"candlestick-chart": `export { CandlestickChart } from "./candlestick-chart";
|
|
||||||
export { Candlestick } from "./candlestick";
|
|
||||||
export { Grid } from "./grid";
|
|
||||||
export { XAxis } from "./x-axis";
|
|
||||||
export { YAxis } from "./y-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
"choropleth-chart": `export { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "./choropleth";`,
|
|
||||||
"live-line-chart": `export { LiveLineChart } from "./live-line-chart";
|
|
||||||
export { LiveLine } from "./live-line";
|
|
||||||
export { LiveXAxis } from "./live-x-axis";
|
|
||||||
export { LiveYAxis } from "./live-y-axis";
|
|
||||||
export { ChartTooltip } from "./tooltip";`,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {Record<string, { registryDependencies: string[], dependencies: string[], importFrom: string, data: string, body: string }>} */
|
|
||||||
const EXAMPLES = {
|
|
||||||
"area-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/area-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/x-axis",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom: "AreaChart, Area, Grid, XAxis, ChartTooltip",
|
|
||||||
data: `const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), desktop: 186 },
|
|
||||||
{ date: new Date("2024-02-01"), desktop: 305 },
|
|
||||||
{ date: new Date("2024-03-01"), desktop: 237 },
|
|
||||||
{ date: new Date("2024-04-01"), desktop: 73 },
|
|
||||||
{ date: new Date("2024-05-01"), desktop: 209 },
|
|
||||||
{ date: new Date("2024-06-01"), desktop: 214 },
|
|
||||||
];`,
|
|
||||||
body: `<AreaChart data={chartData} animationDuration={1100}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Area dataKey="desktop" curve={curveNatural} strokeWidth={2.5} fillOpacity={0.4} />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</AreaChart>`,
|
|
||||||
extraImports: `import { curveNatural } from "@visx/curve";`,
|
|
||||||
},
|
|
||||||
"bar-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/bar-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/gradient@4.0.1-alpha.0",
|
|
||||||
"@visx/pattern@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom: "BarChart, Bar, BarXAxis, Grid, ChartTooltip",
|
|
||||||
data: `const data = [
|
|
||||||
{ month: "Jan", revenue: 12000, profit: 4500 },
|
|
||||||
{ month: "Feb", revenue: 15500, profit: 5200 },
|
|
||||||
{ month: "Mar", revenue: 11000, profit: 3800 },
|
|
||||||
{ month: "Apr", revenue: 18500, profit: 7100 },
|
|
||||||
{ month: "May", revenue: 16800, profit: 5400 },
|
|
||||||
{ month: "Jun", revenue: 21200, profit: 8800 },
|
|
||||||
];`,
|
|
||||||
body: `<BarChart data={data} xDataKey="month">
|
|
||||||
<Grid horizontal />
|
|
||||||
<Bar dataKey="revenue" fill="var(--chart-line-primary)" lineCap="round" />
|
|
||||||
<Bar dataKey="profit" fill="var(--chart-line-secondary)" lineCap="round" />
|
|
||||||
<BarXAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</BarChart>`,
|
|
||||||
},
|
|
||||||
"line-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/line-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/x-axis",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom: "LineChart, Line, Grid, XAxis, ChartTooltip",
|
|
||||||
data: `const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), users: 1200 },
|
|
||||||
{ date: new Date("2024-02-01"), users: 1350 },
|
|
||||||
{ date: new Date("2024-03-01"), users: 1100 },
|
|
||||||
{ date: new Date("2024-04-01"), users: 1450 },
|
|
||||||
{ date: new Date("2024-05-01"), users: 1380 },
|
|
||||||
{ date: new Date("2024-06-01"), users: 1520 },
|
|
||||||
];`,
|
|
||||||
body: `<LineChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Line dataKey="users" curve={curveNatural} stroke="var(--chart-line-primary)" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</LineChart>`,
|
|
||||||
extraImports: `import { curveNatural } from "@visx/curve";`,
|
|
||||||
},
|
|
||||||
"scatter-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/scatter-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/x-axis",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: ["d3-scale", "d3-array", "motion", "react-use-measure"],
|
|
||||||
importFrom: "ScatterChart, Scatter, Grid, XAxis, ChartTooltip",
|
|
||||||
data: `const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), sessions: 420, conversions: 28 },
|
|
||||||
{ date: new Date("2024-02-01"), sessions: 510, conversions: 34 },
|
|
||||||
{ date: new Date("2024-03-01"), sessions: 390, conversions: 22 },
|
|
||||||
{ date: new Date("2024-04-01"), sessions: 580, conversions: 41 },
|
|
||||||
{ date: new Date("2024-05-01"), sessions: 620, conversions: 38 },
|
|
||||||
{ date: new Date("2024-06-01"), sessions: 710, conversions: 52 },
|
|
||||||
];`,
|
|
||||||
body: `<ScatterChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<Scatter dataKey="sessions" />
|
|
||||||
<Scatter dataKey="conversions" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</ScatterChart>`,
|
|
||||||
},
|
|
||||||
"pie-chart": {
|
|
||||||
registryDependencies: ["@bklit/pie-chart"],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom: "PieChart, PieSlice, PieCenter",
|
|
||||||
data: `const pieData = [
|
|
||||||
{ label: "Direct", value: 320 },
|
|
||||||
{ label: "Organic", value: 280 },
|
|
||||||
{ label: "Referral", value: 190 },
|
|
||||||
{ label: "Social", value: 140 },
|
|
||||||
];`,
|
|
||||||
body: `<PieChart data={pieData} size={280}>
|
|
||||||
{pieData.map((item, i) => (
|
|
||||||
<PieSlice index={i} key={item.label} />
|
|
||||||
))}
|
|
||||||
<PieCenter defaultLabel="Traffic" />
|
|
||||||
</PieChart>`,
|
|
||||||
},
|
|
||||||
"gauge-chart": {
|
|
||||||
registryDependencies: ["@bklit/gauge-chart"],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom: "Gauge",
|
|
||||||
data: "",
|
|
||||||
body: `<Gauge
|
|
||||||
value={72}
|
|
||||||
centerValue={72}
|
|
||||||
totalNotches={40}
|
|
||||||
defaultLabel="Score"
|
|
||||||
formatOptions={{ style: "percent" }}
|
|
||||||
/>`,
|
|
||||||
},
|
|
||||||
"heatmap-chart": {
|
|
||||||
registryDependencies: ["@bklit/heatmap-chart"],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/heatmap@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom:
|
|
||||||
"HeatmapCells, HeatmapChart, HeatmapInteractionBoundary, HeatmapInteractionProvider, HeatmapLegend, HeatmapTooltip, HeatmapXAxis, HeatmapYAxis",
|
|
||||||
data: `const data = [
|
|
||||||
{
|
|
||||||
bin: 0,
|
|
||||||
bins: [
|
|
||||||
{ bin: 0, count: 2, date: new Date(2024, 0, 1) },
|
|
||||||
{ bin: 1, count: 0, date: new Date(2024, 0, 2) },
|
|
||||||
{ bin: 2, count: 3, date: new Date(2024, 0, 3) },
|
|
||||||
{ bin: 3, count: 1, date: new Date(2024, 0, 4) },
|
|
||||||
{ bin: 4, count: 4, date: new Date(2024, 0, 5) },
|
|
||||||
{ bin: 5, count: 0, date: new Date(2024, 0, 6) },
|
|
||||||
{ bin: 6, count: 1, date: new Date(2024, 0, 7) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bin: 1,
|
|
||||||
bins: [
|
|
||||||
{ bin: 0, count: 1, date: new Date(2024, 0, 8) },
|
|
||||||
{ bin: 1, count: 2, date: new Date(2024, 0, 9) },
|
|
||||||
{ bin: 2, count: 0, date: new Date(2024, 0, 10) },
|
|
||||||
{ bin: 3, count: 3, date: new Date(2024, 0, 11) },
|
|
||||||
{ bin: 4, count: 2, date: new Date(2024, 0, 12) },
|
|
||||||
{ bin: 5, count: 1, date: new Date(2024, 0, 13) },
|
|
||||||
{ bin: 6, count: 0, date: new Date(2024, 0, 14) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];`,
|
|
||||||
body: `<HeatmapInteractionProvider>
|
|
||||||
<HeatmapInteractionBoundary>
|
|
||||||
<div className="flex w-full flex-col items-stretch gap-3">
|
|
||||||
<HeatmapChart className="w-full" data={data} layout="fluid">
|
|
||||||
<HeatmapCells />
|
|
||||||
<HeatmapXAxis />
|
|
||||||
<HeatmapYAxis />
|
|
||||||
<HeatmapTooltip />
|
|
||||||
</HeatmapChart>
|
|
||||||
<HeatmapLegend />
|
|
||||||
</div>
|
|
||||||
</HeatmapInteractionBoundary>
|
|
||||||
</HeatmapInteractionProvider>`,
|
|
||||||
},
|
|
||||||
"ring-chart": {
|
|
||||||
registryDependencies: ["@bklit/ring-chart"],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom: "RingChart, Ring, RingCenter",
|
|
||||||
data: `const ringData = [
|
|
||||||
{ label: "Email", value: 42 },
|
|
||||||
{ label: "Social", value: 28 },
|
|
||||||
{ label: "Direct", value: 18 },
|
|
||||||
{ label: "Other", value: 12 },
|
|
||||||
];`,
|
|
||||||
body: `<RingChart data={ringData} size={280} strokeWidth={14}>
|
|
||||||
{ringData.map((item, i) => (
|
|
||||||
<Ring index={i} key={item.label} />
|
|
||||||
))}
|
|
||||||
<RingCenter defaultLabel="Channels" />
|
|
||||||
</RingChart>`,
|
|
||||||
},
|
|
||||||
"radar-chart": {
|
|
||||||
registryDependencies: ["@bklit/radar-chart"],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom: "RadarChart, RadarGrid, RadarAxis, RadarLabels, RadarArea",
|
|
||||||
data: `const metrics = [
|
|
||||||
{ key: "speed", label: "Speed" },
|
|
||||||
{ key: "reliability", label: "Reliability" },
|
|
||||||
{ key: "comfort", label: "Comfort" },
|
|
||||||
{ key: "safety", label: "Safety" },
|
|
||||||
{ key: "efficiency", label: "Efficiency" },
|
|
||||||
];
|
|
||||||
const data = [
|
|
||||||
{ id: "a", speed: 80, reliability: 70, comfort: 60, safety: 90, efficiency: 75 },
|
|
||||||
];`,
|
|
||||||
body: `<RadarChart data={data} metrics={metrics} size={320}>
|
|
||||||
<RadarGrid />
|
|
||||||
<RadarAxis />
|
|
||||||
<RadarLabels fontSize={10} offset={16} />
|
|
||||||
{data.map((row, i) => (
|
|
||||||
<RadarArea key={row.id} index={i} fill="var(--chart-line-primary)" fillOpacity={0.35} />
|
|
||||||
))}
|
|
||||||
</RadarChart>`,
|
|
||||||
},
|
|
||||||
"composed-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/composed-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/x-axis",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom:
|
|
||||||
"ComposedChart, SeriesBar, Area, Line, Grid, XAxis, ChartTooltip",
|
|
||||||
data: `const chartData = [
|
|
||||||
{ date: new Date("2024-01-01"), revenue: 4200, runRate: 3800 },
|
|
||||||
{ date: new Date("2024-02-01"), revenue: 5100, runRate: 4600 },
|
|
||||||
{ date: new Date("2024-03-01"), revenue: 4800, runRate: 5200 },
|
|
||||||
{ date: new Date("2024-04-01"), revenue: 5500, runRate: 5000 },
|
|
||||||
];`,
|
|
||||||
body: `<ComposedChart data={chartData}>
|
|
||||||
<Grid horizontal />
|
|
||||||
<SeriesBar dataKey="revenue" fill="var(--chart-1)" />
|
|
||||||
<Area dataKey="runRate" curve={curveNatural} fill="var(--chart-4)" fillOpacity={0.35} />
|
|
||||||
<Line dataKey="runRate" curve={curveNatural} stroke="var(--chart-2)" />
|
|
||||||
<XAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</ComposedChart>`,
|
|
||||||
extraImports: `import { curveNatural } from "@visx/curve";`,
|
|
||||||
},
|
|
||||||
"funnel-chart": {
|
|
||||||
registryDependencies: ["@bklit/funnel-chart"],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom: "FunnelChart",
|
|
||||||
data: `const funnelData = [
|
|
||||||
{ label: "Visitors", value: 12000 },
|
|
||||||
{ label: "Signups", value: 4800 },
|
|
||||||
{ label: "Activated", value: 2100 },
|
|
||||||
{ label: "Paid", value: 840 },
|
|
||||||
];`,
|
|
||||||
body: `<FunnelChart data={funnelData} aspectRatio="2 / 1" />`,
|
|
||||||
},
|
|
||||||
"sankey-chart": {
|
|
||||||
registryDependencies: ["@bklit/sankey-chart"],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/sankey@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom: "SankeyChart, SankeyLink, SankeyNode, SankeyTooltip",
|
|
||||||
data: `const data = {
|
|
||||||
nodes: [
|
|
||||||
{ name: "Ads" },
|
|
||||||
{ name: "Organic" },
|
|
||||||
{ name: "Landing" },
|
|
||||||
{ name: "Product" },
|
|
||||||
{ name: "Checkout" },
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{ source: 0, target: 2, value: 40 },
|
|
||||||
{ source: 1, target: 2, value: 30 },
|
|
||||||
{ source: 2, target: 3, value: 50 },
|
|
||||||
{ source: 3, target: 4, value: 35 },
|
|
||||||
],
|
|
||||||
};`,
|
|
||||||
body: `<SankeyChart data={data} aspectRatio="16 / 9">
|
|
||||||
<SankeyLink />
|
|
||||||
<SankeyNode />
|
|
||||||
<SankeyTooltip />
|
|
||||||
</SankeyChart>`,
|
|
||||||
},
|
|
||||||
"candlestick-chart": {
|
|
||||||
registryDependencies: [
|
|
||||||
"@bklit/candlestick-chart",
|
|
||||||
"@bklit/grid",
|
|
||||||
"@bklit/x-axis",
|
|
||||||
"@bklit/y-axis",
|
|
||||||
"@bklit/chart-tooltip",
|
|
||||||
],
|
|
||||||
dependencies: ["@visx/shape@4.0.1-alpha.0", "motion"],
|
|
||||||
importFrom:
|
|
||||||
"CandlestickChart, Candlestick, Grid, XAxis, YAxis, ChartTooltip",
|
|
||||||
data: `const ohlcData = [
|
|
||||||
{ date: new Date("2024-01-02"), open: 100, high: 108, low: 98, close: 105 },
|
|
||||||
{ date: new Date("2024-01-03"), open: 105, high: 110, low: 102, close: 103 },
|
|
||||||
{ date: new Date("2024-01-04"), open: 103, high: 112, low: 101, close: 110 },
|
|
||||||
{ date: new Date("2024-01-05"), open: 110, high: 115, low: 107, close: 108 },
|
|
||||||
];`,
|
|
||||||
body: `<CandlestickChart data={ohlcData}>
|
|
||||||
<Grid horizontal vertical />
|
|
||||||
<Candlestick />
|
|
||||||
<XAxis />
|
|
||||||
<YAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</CandlestickChart>`,
|
|
||||||
},
|
|
||||||
"choropleth-chart": {
|
|
||||||
registryDependencies: ["@bklit/choropleth-chart"],
|
|
||||||
dependencies: ["@visx/geo@4.0.1-alpha.0", "d3-geo", "topojson-client"],
|
|
||||||
importFrom:
|
|
||||||
"ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip",
|
|
||||||
data: `const features = [
|
|
||||||
{ id: "US", name: "United States", value: 120 },
|
|
||||||
{ id: "CA", name: "Canada", value: 45 },
|
|
||||||
{ id: "GB", name: "United Kingdom", value: 62 },
|
|
||||||
];`,
|
|
||||||
body: `<ChoroplethChart data={features} aspectRatio="2 / 1">
|
|
||||||
<ChoroplethFeatureComponent />
|
|
||||||
<ChoroplethTooltip />
|
|
||||||
</ChoroplethChart>`,
|
|
||||||
},
|
|
||||||
"live-line-chart": {
|
|
||||||
registryDependencies: ["@bklit/live-line-chart", "@bklit/chart-tooltip"],
|
|
||||||
dependencies: [
|
|
||||||
"@visx/curve@4.0.1-alpha.0",
|
|
||||||
"@visx/shape@4.0.1-alpha.0",
|
|
||||||
"motion",
|
|
||||||
],
|
|
||||||
importFrom: "LiveLineChart, LiveLine, LiveXAxis, LiveYAxis, ChartTooltip",
|
|
||||||
data: `const initialData = Array.from({ length: 24 }, (_, i) => ({
|
|
||||||
time: Date.now() - (23 - i) * 60_000,
|
|
||||||
value: 50 + Math.sin(i / 3) * 20,
|
|
||||||
}));`,
|
|
||||||
body: `<LiveLineChart
|
|
||||||
data={initialData}
|
|
||||||
interval={1000}
|
|
||||||
maxPoints={24}
|
|
||||||
xDataKey="time"
|
|
||||||
yDataKey="value"
|
|
||||||
>
|
|
||||||
<LiveLine curve={curveNatural} stroke="var(--chart-line-primary)" />
|
|
||||||
<LiveXAxis />
|
|
||||||
<LiveYAxis />
|
|
||||||
<ChartTooltip />
|
|
||||||
</LiveLineChart>`,
|
|
||||||
extraImports: `import { curveNatural } from "@visx/curve";`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mkdirSync(examplesDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const [slug, spec] of Object.entries(EXAMPLES)) {
|
|
||||||
const extra = spec.extraImports ? `${spec.extraImports}\n\n` : "";
|
|
||||||
const content = `"use client"
|
|
||||||
|
|
||||||
// In your app (monorepo/npm): import { ${spec.importFrom} } from "@bklitui/ui/charts"
|
|
||||||
import { ${spec.importFrom} } from "@/components/charts"
|
|
||||||
|
|
||||||
${extra}${spec.data}
|
|
||||||
|
|
||||||
export default function Component() {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
|
||||||
<div className="w-full max-w-3xl">
|
|
||||||
${spec.body}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
writeFileSync(join(examplesDir, `${slug}.tsx`), content);
|
|
||||||
|
|
||||||
const indexExports = CHART_INDEX_EXPORTS[slug];
|
|
||||||
if (indexExports) {
|
|
||||||
writeFileSync(
|
|
||||||
join(examplesDir, `${slug}-index.ts`),
|
|
||||||
`// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install\n${indexExports}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const registryPath = join(root, "registry.json");
|
|
||||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
||||||
|
|
||||||
registry.items = registry.items.filter(
|
|
||||||
(item) => !item.name.endsWith("-example")
|
|
||||||
);
|
|
||||||
|
|
||||||
const exampleItems = Object.entries(EXAMPLES).map(([slug, spec]) => {
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
path: `registry/examples/${slug}.tsx`,
|
|
||||||
type: "registry:page",
|
|
||||||
target: "app/page.tsx",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (CHART_INDEX_EXPORTS[slug]) {
|
|
||||||
files.push({
|
|
||||||
path: `registry/examples/${slug}-index.ts`,
|
|
||||||
type: "registry:lib",
|
|
||||||
target: "components/charts/index.ts",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: `${slug}-example`,
|
|
||||||
type: "registry:example",
|
|
||||||
title: `${slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} Example`,
|
|
||||||
description: `Composable ${slug} demo for Open in v0`,
|
|
||||||
registryDependencies: spec.registryDependencies,
|
|
||||||
dependencies: spec.dependencies,
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.items.push(...exampleItems);
|
|
||||||
|
|
||||||
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
|
||||||
console.log(`Wrote ${Object.keys(EXAMPLES).length} v0 example registry items`);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const testDir = join(packageRoot, "src/charts/__tests__");
|
|
||||||
|
|
||||||
const tests = readdirSync(testDir)
|
|
||||||
.filter((name) => name.endsWith(".test.ts"))
|
|
||||||
.map((name) => join(testDir, name));
|
|
||||||
|
|
||||||
if (tests.length === 0) {
|
|
||||||
console.error(`No test files found in ${testDir}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
["--import", "tsx", "--test", ...tests],
|
|
||||||
{ stdio: "inherit", cwd: packageRoot }
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Verifies registry item `dependencies` cover all npm imports in the item's
|
|
||||||
* file tree plus transitive @bklit registryDependencies (shadcn / v0 install closure).
|
|
||||||
*/
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const uiRoot = path.join(__dirname, "..");
|
|
||||||
const registryPath = path.join(uiRoot, "registry.json");
|
|
||||||
|
|
||||||
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
|
|
||||||
const byName = Object.fromEntries(
|
|
||||||
registry.items.map((item) => [item.name, item])
|
|
||||||
);
|
|
||||||
|
|
||||||
const importRe =
|
|
||||||
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
||||||
const DEP_VERSION_SUFFIX_RE = /@[\d.]+(-[\w.]+)?$/;
|
|
||||||
const BKLIT_REGISTRY_DEP_RE = /^@bklit\/(.+)$/;
|
|
||||||
|
|
||||||
function getPackageName(spec) {
|
|
||||||
if (spec.startsWith(".") || spec.startsWith("@/")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (spec.startsWith("@")) {
|
|
||||||
const parts = spec.split("/");
|
|
||||||
return `${parts[0]}/${parts[1]}`;
|
|
||||||
}
|
|
||||||
return spec.split("/")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDep(dep) {
|
|
||||||
return dep.replace(DEP_VERSION_SUFFIX_RE, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Import specifiers that are satisfied by @types/* registry dependencies. */
|
|
||||||
const TYPES_ALIASES = {
|
|
||||||
geojson: ["geojson", "@types/geojson"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function isDeclared(pkg, declared) {
|
|
||||||
if (declared.has(pkg)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const aliases = TYPES_ALIASES[pkg];
|
|
||||||
if (aliases) {
|
|
||||||
return aliases.some((name) => declared.has(name));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanFiles(files) {
|
|
||||||
const used = new Set();
|
|
||||||
for (const file of files ?? []) {
|
|
||||||
const fullPath = path.join(uiRoot, file.path);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
throw new Error(`Registry file not found: ${file.path}`);
|
|
||||||
}
|
|
||||||
const content = fs.readFileSync(fullPath, "utf8");
|
|
||||||
importRe.lastIndex = 0;
|
|
||||||
let match = importRe.exec(content);
|
|
||||||
while (match) {
|
|
||||||
const pkg = getPackageName(match[1]);
|
|
||||||
if (pkg && pkg !== "react" && pkg !== "react-dom") {
|
|
||||||
used.add(pkg);
|
|
||||||
}
|
|
||||||
match = importRe.exec(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return used;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRegistryDepName(dep) {
|
|
||||||
const m = dep.match(BKLIT_REGISTRY_DEP_RE);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectDeclared(name, visited = new Set()) {
|
|
||||||
const declared = new Set();
|
|
||||||
const visit = (itemName) => {
|
|
||||||
if (visited.has(itemName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
visited.add(itemName);
|
|
||||||
const item = byName[itemName];
|
|
||||||
if (!item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const dep of item.dependencies ?? []) {
|
|
||||||
declared.add(normalizeDep(dep));
|
|
||||||
}
|
|
||||||
for (const regDep of item.registryDependencies ?? []) {
|
|
||||||
const nested = getRegistryDepName(regDep);
|
|
||||||
if (nested) {
|
|
||||||
visit(nested);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(name);
|
|
||||||
return declared;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectUsed(name, visited = new Set()) {
|
|
||||||
const used = new Set();
|
|
||||||
const visit = (itemName) => {
|
|
||||||
if (visited.has(itemName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
visited.add(itemName);
|
|
||||||
const item = byName[itemName];
|
|
||||||
if (!item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const pkg of scanFiles(item.files)) {
|
|
||||||
used.add(pkg);
|
|
||||||
}
|
|
||||||
for (const regDep of item.registryDependencies ?? []) {
|
|
||||||
const nested = getRegistryDepName(regDep);
|
|
||||||
if (nested) {
|
|
||||||
visit(nested);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(name);
|
|
||||||
return used;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartItems = registry.items.filter(
|
|
||||||
(item) =>
|
|
||||||
item.type === "registry:component" &&
|
|
||||||
(item.name.endsWith("-chart") ||
|
|
||||||
item.name === "chart-context" ||
|
|
||||||
item.name === "chart-animation")
|
|
||||||
);
|
|
||||||
|
|
||||||
const utilityItems = registry.items.filter((item) =>
|
|
||||||
["grid", "x-axis", "y-axis", "chart-tooltip", "legend", "markers"].includes(
|
|
||||||
item.name
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const failures = [];
|
|
||||||
|
|
||||||
for (const item of [...chartItems, ...utilityItems]) {
|
|
||||||
const declared = collectDeclared(item.name);
|
|
||||||
const used = collectUsed(item.name);
|
|
||||||
const missing = [...used].filter((pkg) => !isDeclared(pkg, declared)).sort();
|
|
||||||
if (missing.length > 0) {
|
|
||||||
failures.push({ name: item.name, missing });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.error("Registry dependency closure check failed:\n");
|
|
||||||
for (const { name, missing } of failures) {
|
|
||||||
console.error(` ${name}: missing ${missing.join(", ")}`);
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
"\nAdd missing packages to `dependencies` in packages/ui/registry.json (on the item or a registryDependency)."
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Registry dependency closure OK (${chartItems.length + utilityItems.length} items checked).`
|
|
||||||
);
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Verifies every installable registry item ships all local `./` imports in its
|
|
||||||
* transitive file closure (item files + registryDependencies).
|
|
||||||
*
|
|
||||||
* Also rejects CommonJS `require("react-dom")` in registry source files — it
|
|
||||||
* breaks Vite/ESM consumers even though Next.js/webpack may tolerate it.
|
|
||||||
*/
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const uiRoot = path.join(__dirname, "..");
|
|
||||||
const registryPath = path.join(uiRoot, "registry.json");
|
|
||||||
|
|
||||||
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
|
|
||||||
const byName = Object.fromEntries(
|
|
||||||
registry.items.map((item) => [item.name, item])
|
|
||||||
);
|
|
||||||
|
|
||||||
const importRe =
|
|
||||||
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"](\.[^'"]+)['"]/g;
|
|
||||||
const BKLIT_REGISTRY_DEP_RE = /^@bklit\/(.+)$/;
|
|
||||||
|
|
||||||
const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
||||||
|
|
||||||
function resolveRelativeImport(fromFilePath, spec) {
|
|
||||||
const dir = path.dirname(fromFilePath);
|
|
||||||
const base = path.normalize(path.join(dir, spec));
|
|
||||||
const candidates = [
|
|
||||||
base,
|
|
||||||
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
|
|
||||||
...RESOLVE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const fullPath = path.join(uiRoot, candidate);
|
|
||||||
if (fs.existsSync(fullPath)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectItemFiles(itemName, visited = new Set()) {
|
|
||||||
const files = new Set();
|
|
||||||
const visit = (name) => {
|
|
||||||
if (visited.has(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
visited.add(name);
|
|
||||||
const item = byName[name];
|
|
||||||
if (!item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const file of item.files ?? []) {
|
|
||||||
files.add(file.path);
|
|
||||||
}
|
|
||||||
for (const regDep of item.registryDependencies ?? []) {
|
|
||||||
const nested = regDep.match(BKLIT_REGISTRY_DEP_RE)?.[1];
|
|
||||||
if (nested) {
|
|
||||||
visit(nested);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(itemName);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanRelativeImports(filePath) {
|
|
||||||
// v0/shadcn example barrels re-export co-installed chart files from the
|
|
||||||
// target `components/charts/` directory — not from registry source paths.
|
|
||||||
if (
|
|
||||||
filePath.startsWith("registry/examples/") &&
|
|
||||||
filePath.endsWith("-index.ts")
|
|
||||||
) {
|
|
||||||
return { imports: [], content: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = path.join(uiRoot, filePath);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
throw new Error(`Registry file not found: ${filePath}`);
|
|
||||||
}
|
|
||||||
const content = fs.readFileSync(fullPath, "utf8");
|
|
||||||
const imports = [];
|
|
||||||
importRe.lastIndex = 0;
|
|
||||||
let match = importRe.exec(content);
|
|
||||||
while (match) {
|
|
||||||
imports.push(match[1]);
|
|
||||||
match = importRe.exec(content);
|
|
||||||
}
|
|
||||||
return { imports, content };
|
|
||||||
}
|
|
||||||
|
|
||||||
const INSTALLABLE_TYPES = new Set([
|
|
||||||
"registry:component",
|
|
||||||
"registry:lib",
|
|
||||||
"registry:block",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const installableItems = registry.items.filter((item) =>
|
|
||||||
INSTALLABLE_TYPES.has(item.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
const closureFailures = [];
|
|
||||||
const requireFailures = [];
|
|
||||||
|
|
||||||
for (const item of installableItems) {
|
|
||||||
const closure = collectItemFiles(item.name);
|
|
||||||
const missing = new Map();
|
|
||||||
|
|
||||||
for (const filePath of closure) {
|
|
||||||
const { imports, content } = scanRelativeImports(filePath);
|
|
||||||
|
|
||||||
if (content.includes('require("react-dom")')) {
|
|
||||||
requireFailures.push({ item: item.name, file: filePath });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const spec of imports) {
|
|
||||||
const resolved = resolveRelativeImport(filePath, spec);
|
|
||||||
if (!resolved) {
|
|
||||||
const key = `${filePath} -> ${spec}`;
|
|
||||||
if (!missing.has(key)) {
|
|
||||||
missing.set(key, "missing on disk");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!closure.has(resolved)) {
|
|
||||||
const key = `${filePath} -> ${spec} (${resolved})`;
|
|
||||||
if (!missing.has(key)) {
|
|
||||||
missing.set(key, "not in install closure");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missing.size > 0) {
|
|
||||||
closureFailures.push({ name: item.name, missing: [...missing.entries()] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let failed = false;
|
|
||||||
|
|
||||||
if (closureFailures.length > 0) {
|
|
||||||
failed = true;
|
|
||||||
console.error("Registry local import closure check failed:\n");
|
|
||||||
for (const { name, missing } of closureFailures) {
|
|
||||||
console.error(` ${name}:`);
|
|
||||||
for (const [ref, reason] of missing) {
|
|
||||||
console.error(` - ${ref} (${reason})`);
|
|
||||||
}
|
|
||||||
console.error("");
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
"Add missing files to packages/ui/registry.json (on the item or a registryDependency)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireFailures.length > 0) {
|
|
||||||
failed = true;
|
|
||||||
console.error('Registry source must not use require("react-dom"):\n');
|
|
||||||
for (const { item, file } of requireFailures) {
|
|
||||||
console.error(` ${item}: ${file}`);
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
'\nUse `import { createPortal } from "react-dom"` in "use client" components instead.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Registry file closure OK (${installableItems.length} installable items checked).`
|
|
||||||
);
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Ensures shadcn registry output matches chart source when packages/ui/src/charts changes.
|
|
||||||
* Run via `pnpm registry:verify` (locally or in CI).
|
|
||||||
*/
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const repoRoot = join(__dirname, "../../..");
|
|
||||||
|
|
||||||
const CHARTS_SRC = "packages/ui/src/charts";
|
|
||||||
const REGISTRY_PATHS = [
|
|
||||||
"apps/web/public/r",
|
|
||||||
"packages/ui/registry.json",
|
|
||||||
"packages/ui/registry/examples",
|
|
||||||
"packages/ui/registry/blocks",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Build scripts may reformat these; compare parsed JSON, not raw text. */
|
|
||||||
const SEMANTIC_JSON_PATHS = [
|
|
||||||
"packages/ui/registry.json",
|
|
||||||
"apps/web/public/r/registry.json",
|
|
||||||
];
|
|
||||||
|
|
||||||
function run(cmd, { inherit = false } = {}) {
|
|
||||||
return execSync(cmd, {
|
|
||||||
cwd: repoRoot,
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: inherit ? "inherit" : "pipe",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson(relativePath) {
|
|
||||||
return JSON.parse(fs.readFileSync(join(repoRoot, relativePath), "utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonEqual(a, b) {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDiffRange() {
|
|
||||||
if (process.env.REGISTRY_SYNC_DIFF_RANGE) {
|
|
||||||
return process.env.REGISTRY_SYNC_DIFF_RANGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.GITHUB_EVENT_NAME === "pull_request") {
|
|
||||||
const base = process.env.GITHUB_BASE_REF || "main";
|
|
||||||
return `origin/${base}...HEAD`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
process.env.GITHUB_EVENT_NAME === "push" &&
|
|
||||||
process.env.GITHUB_EVENT_BEFORE &&
|
|
||||||
process.env.GITHUB_SHA
|
|
||||||
) {
|
|
||||||
return `${process.env.GITHUB_EVENT_BEFORE}...${process.env.GITHUB_SHA}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "origin/main...HEAD";
|
|
||||||
}
|
|
||||||
|
|
||||||
function chartsChanged(diffRange) {
|
|
||||||
const changed = run(
|
|
||||||
`git diff --name-only ${diffRange} -- ${CHARTS_SRC}`
|
|
||||||
).trim();
|
|
||||||
return changed.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDirtyRegistryFiles() {
|
|
||||||
const excludeArgs = SEMANTIC_JSON_PATHS.map((p) => `':!${p}'`).join(" ");
|
|
||||||
const paths = REGISTRY_PATHS.join(" ");
|
|
||||||
const status = run(
|
|
||||||
`git status --porcelain -- ${paths} ${excludeArgs}`
|
|
||||||
).trim();
|
|
||||||
if (!status) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return status.split("\n").filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listSemanticRegistryDrift(beforeByPath) {
|
|
||||||
const drift = [];
|
|
||||||
for (const relativePath of SEMANTIC_JSON_PATHS) {
|
|
||||||
const after = readJson(relativePath);
|
|
||||||
if (!jsonEqual(beforeByPath[relativePath], after)) {
|
|
||||||
drift.push(relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return drift;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const diffRange = resolveDiffRange();
|
|
||||||
|
|
||||||
if (!chartsChanged(diffRange)) {
|
|
||||||
console.log(
|
|
||||||
`No changes in ${CHARTS_SRC} (${diffRange}); registry sync check skipped.`
|
|
||||||
);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Chart source changed (${diffRange}); rebuilding registry to verify sync...`
|
|
||||||
);
|
|
||||||
|
|
||||||
const registryBefore = Object.fromEntries(
|
|
||||||
SEMANTIC_JSON_PATHS.map((relativePath) => [
|
|
||||||
relativePath,
|
|
||||||
readJson(relativePath),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
run("pnpm registry:build", { inherit: true });
|
|
||||||
|
|
||||||
const semanticDrift = listSemanticRegistryDrift(registryBefore);
|
|
||||||
const dirty = listDirtyRegistryFiles();
|
|
||||||
const failures = [
|
|
||||||
...semanticDrift.map((path) => `${path} (content changed)`),
|
|
||||||
...dirty,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.error("\nRegistry is out of sync with chart source changes.\n");
|
|
||||||
console.error(
|
|
||||||
"Run `pnpm registry:build` from the repo root and commit the updated registry files:\n"
|
|
||||||
);
|
|
||||||
for (const line of failures) {
|
|
||||||
console.error(` ${line}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Registry is in sync with chart source.");
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
appName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Button = ({ children, className, appName }: ButtonProps) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={className}
|
|
||||||
onClick={() => console.log(`Hello from your ${appName} app!`)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import type { JSX } from "react";
|
|
||||||
|
|
||||||
export function Card({
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
href: string;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={className}
|
|
||||||
href={`${href}?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo"`}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<h2>
|
|
||||||
{title} <span>-></span>
|
|
||||||
</h2>
|
|
||||||
<p>{children}</p>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { GridColumns, GridRows } from "@visx/grid";
|
|
||||||
import type { scaleLinear, scaleTime } from "@visx/scale";
|
|
||||||
import { useId } from "react";
|
|
||||||
|
|
||||||
type ScaleLinear<Output, _Input = number> = ReturnType<
|
|
||||||
typeof scaleLinear<Output>
|
|
||||||
>;
|
|
||||||
type ScaleTime<Output, _Input = Date | number> = ReturnType<
|
|
||||||
typeof scaleTime<Output>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ChartGridProps {
|
|
||||||
/** Width of the grid area */
|
|
||||||
width: number;
|
|
||||||
/** Height of the grid area */
|
|
||||||
height: number;
|
|
||||||
/** X scale (time scale) */
|
|
||||||
xScale: ScaleTime<number, number>;
|
|
||||||
/** Y scale (linear scale) */
|
|
||||||
yScale: ScaleLinear<number, number>;
|
|
||||||
/** Show horizontal grid lines. Default: true */
|
|
||||||
showRows?: boolean;
|
|
||||||
/** Show vertical grid lines. Default: false */
|
|
||||||
showColumns?: boolean;
|
|
||||||
/** Number of horizontal grid lines. Default: 5 */
|
|
||||||
numTicksRows?: number;
|
|
||||||
/** Number of vertical grid lines. Default: 10 */
|
|
||||||
numTicksColumns?: number;
|
|
||||||
/** Grid line stroke color. Default: var(--chart-grid) */
|
|
||||||
stroke?: string;
|
|
||||||
/** Grid line stroke opacity. Default: 1 */
|
|
||||||
strokeOpacity?: number;
|
|
||||||
/** Grid line stroke width. Default: 1 */
|
|
||||||
strokeWidth?: number;
|
|
||||||
/** Grid line dash array. Default: "4,4" for dashed lines */
|
|
||||||
strokeDasharray?: string;
|
|
||||||
/** Enable horizontal fade effect on grid rows. Default: true */
|
|
||||||
fadeRows?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChartGrid({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
xScale,
|
|
||||||
yScale,
|
|
||||||
showRows = true,
|
|
||||||
showColumns = false,
|
|
||||||
numTicksRows = 5,
|
|
||||||
numTicksColumns = 10,
|
|
||||||
stroke = "var(--chart-grid)",
|
|
||||||
strokeOpacity = 1,
|
|
||||||
strokeWidth = 1,
|
|
||||||
strokeDasharray = "4,4",
|
|
||||||
fadeRows = true,
|
|
||||||
}: ChartGridProps) {
|
|
||||||
const uniqueId = useId();
|
|
||||||
const maskId = `chart-grid-rows-fade-${uniqueId}`;
|
|
||||||
const gradientId = `${maskId}-gradient`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g className="chart-grid">
|
|
||||||
{/* Gradient mask for horizontal grid lines - fades at both ends */}
|
|
||||||
{showRows && fadeRows && (
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
|
|
||||||
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
|
|
||||||
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
|
|
||||||
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
style={{ stopColor: "white", stopOpacity: 0 }}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
<mask id={maskId}>
|
|
||||||
<rect
|
|
||||||
fill={`url(#${gradientId})`}
|
|
||||||
height={height}
|
|
||||||
width={width}
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRows && (
|
|
||||||
<g mask={fadeRows ? `url(#${maskId})` : undefined}>
|
|
||||||
<GridRows
|
|
||||||
numTicks={numTicksRows}
|
|
||||||
scale={yScale}
|
|
||||||
stroke={stroke}
|
|
||||||
strokeDasharray={strokeDasharray}
|
|
||||||
strokeOpacity={strokeOpacity}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
width={width}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
{showColumns && (
|
|
||||||
<GridColumns
|
|
||||||
height={height}
|
|
||||||
numTicks={numTicksColumns}
|
|
||||||
scale={xScale}
|
|
||||||
stroke={stroke}
|
|
||||||
strokeDasharray={strokeDasharray}
|
|
||||||
strokeOpacity={strokeOpacity}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChartGrid;
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
clipRevealTransition,
|
|
||||||
DEFAULT_CHART_ENTER_TRANSITION,
|
|
||||||
} from "../animation";
|
|
||||||
|
|
||||||
describe("clipRevealTransition", () => {
|
|
||||||
it("preserves explicit tween ease", () => {
|
|
||||||
const tween = {
|
|
||||||
type: "tween" as const,
|
|
||||||
duration: 0.9,
|
|
||||||
ease: [0.1, 0.2, 0.3, 0.4] as [number, number, number, number],
|
|
||||||
};
|
|
||||||
assert.deepEqual(clipRevealTransition(tween), tween);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies default ease when tween omits ease", () => {
|
|
||||||
const result = clipRevealTransition({
|
|
||||||
type: "tween",
|
|
||||||
duration: 0.9,
|
|
||||||
});
|
|
||||||
assert.equal(result.type, "tween");
|
|
||||||
assert.equal(result.duration, 0.9);
|
|
||||||
assert.deepEqual(result.ease, DEFAULT_CHART_ENTER_TRANSITION.ease);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts spring to tween for svg width reveal", () => {
|
|
||||||
const result = clipRevealTransition({
|
|
||||||
type: "spring",
|
|
||||||
duration: 1.2,
|
|
||||||
bounce: 0.5,
|
|
||||||
});
|
|
||||||
assert.equal(result.type, "tween");
|
|
||||||
assert.equal(result.duration, 1.2);
|
|
||||||
assert.deepEqual(result.ease, DEFAULT_CHART_ENTER_TRANSITION.ease);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
BAR_DEPTH_MAX_PX,
|
|
||||||
BAR_DEPTH_PERSPECTIVE_RATIO,
|
|
||||||
barDepthAndRise,
|
|
||||||
barDepthMaxDepth,
|
|
||||||
} from "../bar-depth-geometry";
|
|
||||||
|
|
||||||
describe("barDepthMaxDepth", () => {
|
|
||||||
it("caps at BAR_DEPTH_MAX_PX when the gap is wide", () => {
|
|
||||||
// step 100, band 90 → gap 10, gap-1=9; band*0.22=19.8 → min(19.8, 9, 7) = 7
|
|
||||||
assert.equal(barDepthMaxDepth(100, 90), BAR_DEPTH_MAX_PX);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is bounded by the inter-bar gap on dense charts", () => {
|
|
||||||
// step 50, band 48 → gap 2, gap-1=1; band*0.22=10.56 → min(10.56, 1, 7) = 1
|
|
||||||
assert.equal(barDepthMaxDepth(50, 48), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is 0 when there is no gap between bars", () => {
|
|
||||||
assert.equal(barDepthMaxDepth(50, 50), 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("barDepthAndRise", () => {
|
|
||||||
it("returns no depth for a dead-center bar (absOffset 0)", () => {
|
|
||||||
assert.deepEqual(barDepthAndRise(0, 100, 7), {
|
|
||||||
depth: 0,
|
|
||||||
perspectiveRise: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scales depth with offset, capped by maxDepth at the edge", () => {
|
|
||||||
assert.equal(barDepthAndRise(1, 100, 7).depth, 7);
|
|
||||||
assert.equal(barDepthAndRise(0.5, 100, 7).depth, 3.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps depth by the bar height so short bars stay proportional", () => {
|
|
||||||
// naturalHeight 4 < maxDepth 7 → depth capped at 4
|
|
||||||
assert.equal(barDepthAndRise(1, 4, 7).depth, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the rise from depth via the perspective ratio", () => {
|
|
||||||
const { depth, perspectiveRise } = barDepthAndRise(0.5, 100, 7);
|
|
||||||
assert.equal(perspectiveRise, depth * BAR_DEPTH_PERSPECTIVE_RATIO);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps out-of-range inputs defensively", () => {
|
|
||||||
assert.equal(barDepthAndRise(2, 100, 7).depth, 7); // absOffset clamped to 1
|
|
||||||
assert.equal(barDepthAndRise(-1, 100, 7).depth, 0); // clamped to 0
|
|
||||||
assert.equal(barDepthAndRise(1, -5, 7).depth, 0); // negative height → 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
hmsTimeFmt,
|
|
||||||
intFmt,
|
|
||||||
shortDateFmt,
|
|
||||||
weekdayDateFmt,
|
|
||||||
} from "../chart-formatters";
|
|
||||||
|
|
||||||
const sampleDates = [
|
|
||||||
new Date(2025, 0, 5, 9, 8, 7),
|
|
||||||
new Date(2024, 11, 31, 23, 59, 59),
|
|
||||||
new Date(2026, 6, 4, 12, 0, 0),
|
|
||||||
];
|
|
||||||
|
|
||||||
const sampleNumbers = [0, 42, 1234, 1_234_567, -9876.5];
|
|
||||||
|
|
||||||
describe("chart-formatters", () => {
|
|
||||||
describe("shortDateFmt", () => {
|
|
||||||
for (const date of sampleDates) {
|
|
||||||
it(`matches toLocaleDateString for ${date.toISOString()}`, () => {
|
|
||||||
assert.equal(
|
|
||||||
shortDateFmt.format(date),
|
|
||||||
date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("weekdayDateFmt", () => {
|
|
||||||
for (const date of sampleDates) {
|
|
||||||
it(`matches toLocaleDateString for ${date.toISOString()}`, () => {
|
|
||||||
assert.equal(
|
|
||||||
weekdayDateFmt.format(date),
|
|
||||||
date.toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hmsTimeFmt", () => {
|
|
||||||
for (const date of sampleDates) {
|
|
||||||
it(`matches toLocaleTimeString for ${date.toISOString()}`, () => {
|
|
||||||
assert.equal(
|
|
||||||
hmsTimeFmt.format(date),
|
|
||||||
date.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("intFmt", () => {
|
|
||||||
for (const value of sampleNumbers) {
|
|
||||||
it(`matches toLocaleString for ${value}`, () => {
|
|
||||||
assert.equal(intFmt(value), value.toLocaleString("en-US"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("is a reusable formatter function", () => {
|
|
||||||
const formatValue = intFmt;
|
|
||||||
assert.equal(formatValue(1000), (1000).toLocaleString("en-US"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
decimateOhlcData,
|
|
||||||
decimateTimeSeries,
|
|
||||||
maxRenderPointsForWidth,
|
|
||||||
} from "../decimate-time-series";
|
|
||||||
|
|
||||||
describe("decimateTimeSeries", () => {
|
|
||||||
it("returns the original array when under the point budget", () => {
|
|
||||||
const data = [{ v: 1 }, { v: 2 }, { v: 3 }];
|
|
||||||
assert.equal(decimateTimeSeries(data, 10), data);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("always keeps the first and last points", () => {
|
|
||||||
const data = Array.from({ length: 100 }, (_, i) => ({ v: i }));
|
|
||||||
const sampled = decimateTimeSeries(data, 20, ["v"]);
|
|
||||||
assert.equal(sampled[0]?.v, 0);
|
|
||||||
assert.equal(sampled.at(-1)?.v, 99);
|
|
||||||
assert.equal(sampled.length, 20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves spikes in the series", () => {
|
|
||||||
const data = Array.from({ length: 50 }, (_, i) => ({
|
|
||||||
v: i === 25 ? 1000 : i,
|
|
||||||
}));
|
|
||||||
const sampled = decimateTimeSeries(data, 10, ["v"]);
|
|
||||||
assert(sampled.some((point) => point.v === 1000));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decimateOhlcData", () => {
|
|
||||||
it("preserves bucket high/low extremes", () => {
|
|
||||||
const data = Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
date: new Date(i),
|
|
||||||
open: i,
|
|
||||||
high: i === 50 ? 999 : i + 5,
|
|
||||||
low: i === 50 ? 1 : i - 5,
|
|
||||||
close: i + 1,
|
|
||||||
}));
|
|
||||||
const sampled = decimateOhlcData(data, 10);
|
|
||||||
assert(sampled.some((row) => row.high === 999));
|
|
||||||
assert(sampled.some((row) => row.low === 1));
|
|
||||||
assert.equal(sampled.length, 10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("maxRenderPointsForWidth", () => {
|
|
||||||
it("returns at least 64 points", () => {
|
|
||||||
assert.equal(maxRenderPointsForWidth(10), 64);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scales with chart width", () => {
|
|
||||||
assert.equal(maxRenderPointsForWidth(400), 600);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { computeSegmentBounds } from "../highlight-segment-bounds";
|
|
||||||
|
|
||||||
// Data points at t = 0,10,20,30 ms; a linear pixel xScale (1px per ms) keeps the
|
|
||||||
// arithmetic obvious — the band is the pixel x-range one data point either side.
|
|
||||||
const data = [{ t: 0 }, { t: 10 }, { t: 20 }, { t: 30 }];
|
|
||||||
const xAccessor = (d: Record<string, unknown>) => new Date(d.t as number);
|
|
||||||
const xScale = (value: Date) => value.getTime();
|
|
||||||
|
|
||||||
describe("computeSegmentBounds", () => {
|
|
||||||
it("is inactive for empty data", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
computeSegmentBounds([], xScale, xAccessor, { index: 1 }, null),
|
|
||||||
{ x: 0, width: 0, isActive: false }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is inactive with no hover and no selection", () => {
|
|
||||||
assert.equal(
|
|
||||||
computeSegmentBounds(data, xScale, xAccessor, null, null).isActive,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spans one data point either side of the hovered index", () => {
|
|
||||||
// idx 1 → [t 0, t 20] → x 0, width 20
|
|
||||||
assert.deepEqual(
|
|
||||||
computeSegmentBounds(data, xScale, xAccessor, { index: 1 }, null),
|
|
||||||
{ x: 0, width: 20, isActive: true }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps the start at the first index", () => {
|
|
||||||
// idx 0 → [t 0, t 10] → x 0, width 10
|
|
||||||
assert.deepEqual(
|
|
||||||
computeSegmentBounds(data, xScale, xAccessor, { index: 0 }, null),
|
|
||||||
{ x: 0, width: 10, isActive: true }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps the end at the last index", () => {
|
|
||||||
// idx 3 (last) → [t 20, t 30] → x 20, width 10
|
|
||||||
assert.deepEqual(
|
|
||||||
computeSegmentBounds(data, xScale, xAccessor, { index: 3 }, null),
|
|
||||||
{ x: 20, width: 10, isActive: true }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the dragged pixel range for an active selection (takes priority)", () => {
|
|
||||||
const bounds = computeSegmentBounds(
|
|
||||||
data,
|
|
||||||
xScale,
|
|
||||||
xAccessor,
|
|
||||||
{ index: 1 }, // ignored — selection wins
|
|
||||||
{ active: true, startX: 5, endX: 25 }
|
|
||||||
);
|
|
||||||
assert.deepEqual(bounds, { x: 5, width: 20, isActive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes a reversed (right-to-left) selection drag", () => {
|
|
||||||
const bounds = computeSegmentBounds(data, xScale, xAccessor, null, {
|
|
||||||
active: true,
|
|
||||||
startX: 25,
|
|
||||||
endX: 5,
|
|
||||||
});
|
|
||||||
assert.deepEqual(bounds, { x: 5, width: 20, isActive: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { resolveLineLoadingPulseMode } from "../line-loading-pulse";
|
|
||||||
|
|
||||||
describe("resolveLineLoadingPulseMode", () => {
|
|
||||||
it("maps loading phases to pulse modes", () => {
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("loading"), "loop");
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("exiting"), "exit");
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("revealingLoading"), "enter");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-loading phases", () => {
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("ready"), null);
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("revealing"), null);
|
|
||||||
assert.equal(resolveLineLoadingPulseMode("exitingReady"), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { getSkeletonHeights } from "../loading-sweep";
|
|
||||||
|
|
||||||
describe("getSkeletonHeights", () => {
|
|
||||||
it("returns the requested number of heights", () => {
|
|
||||||
assert.equal(getSkeletonHeights(12).length, 12);
|
|
||||||
assert.equal(getSkeletonHeights(0).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is deterministic for the same (count, seed) — SSR-safe", () => {
|
|
||||||
assert.deepEqual(getSkeletonHeights(8, 3), getSkeletonHeights(8, 3));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-rolls when the seed changes", () => {
|
|
||||||
assert.notDeepEqual(getSkeletonHeights(8, 0), getSkeletonHeights(8, 1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays within the default [20, 80) range", () => {
|
|
||||||
for (const h of getSkeletonHeights(50, 7)) {
|
|
||||||
assert.ok(h >= 20 && h < 80, `height ${h} out of default range`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects a custom range", () => {
|
|
||||||
for (const h of getSkeletonHeights(50, 2, 40, 60)) {
|
|
||||||
assert.ok(h >= 40 && h < 60, `height ${h} out of [40, 60)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { splitProfitLossSegments } from "../profit-loss-segments";
|
|
||||||
|
|
||||||
const xAccessor = (d: Record<string, unknown>) => d.date as Date;
|
|
||||||
|
|
||||||
describe("splitProfitLossSegments", () => {
|
|
||||||
it("returns empty array for no data", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
splitProfitLossSegments({
|
|
||||||
data: [],
|
|
||||||
dataKey: "pnl",
|
|
||||||
xAccessor,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits at zero crossings with interpolated crossing points", () => {
|
|
||||||
const data = [
|
|
||||||
{ date: new Date("2024-01-01"), pnl: 100 },
|
|
||||||
{ date: new Date("2024-01-02"), pnl: -50 },
|
|
||||||
{ date: new Date("2024-01-03"), pnl: -20 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const segments = splitProfitLossSegments({
|
|
||||||
data,
|
|
||||||
dataKey: "pnl",
|
|
||||||
xAccessor,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(segments.length, 2);
|
|
||||||
assert.equal(segments[0]?.isPositive, true);
|
|
||||||
assert.equal(segments[1]?.isPositive, false);
|
|
||||||
assert.equal(segments[0]?.data.at(-1)?.pnl, 0);
|
|
||||||
assert.equal(segments[1]?.data[0]?.pnl, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a single segment when values stay on one side of zero", () => {
|
|
||||||
const data = [
|
|
||||||
{ date: new Date("2024-01-01"), pnl: 10 },
|
|
||||||
{ date: new Date("2024-01-02"), pnl: 20 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const segments = splitProfitLossSegments({
|
|
||||||
data,
|
|
||||||
dataKey: "pnl",
|
|
||||||
xAccessor,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(segments.length, 1);
|
|
||||||
assert.equal(segments[0]?.isPositive, true);
|
|
||||||
assert.equal(segments[0]?.data.length, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
buildHorizontalTangentBezierPath,
|
|
||||||
buildProjectionPath,
|
|
||||||
} from "../projection-utils";
|
|
||||||
|
|
||||||
const sourceData = Array.from({ length: 6 }, (_, index) => ({
|
|
||||||
date: new Date(2024, 0, index + 1),
|
|
||||||
desktop: 100 + index * 10,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("buildProjectionPath", () => {
|
|
||||||
it("extends auto projections from the last point to the horizon", () => {
|
|
||||||
const path = buildProjectionPath({
|
|
||||||
sourceData,
|
|
||||||
seriesKey: "desktop",
|
|
||||||
mode: "auto",
|
|
||||||
autoMethod: "linearRegression",
|
|
||||||
horizonPoints: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(path.length, 2);
|
|
||||||
assert.equal(path[0]?.value, 150);
|
|
||||||
assert.ok((path.at(-1)?.value ?? 0) > 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a target projection to an end value", () => {
|
|
||||||
const path = buildProjectionPath({
|
|
||||||
sourceData,
|
|
||||||
seriesKey: "desktop",
|
|
||||||
mode: "target",
|
|
||||||
horizonPoints: 3,
|
|
||||||
endValue: 220,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(path.length, 2);
|
|
||||||
assert.equal(path[0]?.value, 150);
|
|
||||||
assert.equal(path[1]?.value, 220);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const HORIZONTAL_TANGENT_BEZIER_PATH = /^M 0,100 C 90,100 110,50 200,50$/;
|
|
||||||
|
|
||||||
describe("buildHorizontalTangentBezierPath", () => {
|
|
||||||
it("uses horizontal control points at start and end y", () => {
|
|
||||||
const path = buildHorizontalTangentBezierPath(0, 100, 200, 50);
|
|
||||||
assert.match(path, HORIZONTAL_TANGENT_BEZIER_PATH);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
computeReferenceAreaRect,
|
|
||||||
resolveReferenceDataRange,
|
|
||||||
} from "../reference-area-geometry";
|
|
||||||
|
|
||||||
const innerWidth = 400;
|
|
||||||
const innerHeight = 200;
|
|
||||||
|
|
||||||
const xScale = (date: Date) => date.getTime() / 10;
|
|
||||||
const yScale = (value: number) => innerHeight - value;
|
|
||||||
|
|
||||||
function baseOptions(
|
|
||||||
overrides: Partial<Parameters<typeof computeReferenceAreaRect>[0]> = {}
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
innerWidth,
|
|
||||||
innerHeight,
|
|
||||||
xScale,
|
|
||||||
yScale,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("computeReferenceAreaRect", () => {
|
|
||||||
it("maps a full-width horizontal band between y1 and y2", () => {
|
|
||||||
const rect = computeReferenceAreaRect(baseOptions({ y1: 40, y2: 80 }));
|
|
||||||
assert.deepEqual(rect, { x: 0, y: 120, width: 400, height: 40 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a partial x-range when x1 and x2 are set", () => {
|
|
||||||
const rect = computeReferenceAreaRect(
|
|
||||||
baseOptions({
|
|
||||||
x1: new Date(1000),
|
|
||||||
x2: new Date(2000),
|
|
||||||
y1: 50,
|
|
||||||
y2: 100,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert.deepEqual(rect, { x: 100, y: 100, width: 100, height: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps to the plot when ifOverflow is hidden", () => {
|
|
||||||
const rect = computeReferenceAreaRect(
|
|
||||||
baseOptions({
|
|
||||||
y1: 150,
|
|
||||||
y2: 250,
|
|
||||||
ifOverflow: "hidden",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert.deepEqual(rect, { x: 0, y: 0, width: 400, height: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when discard and partly outside the plot", () => {
|
|
||||||
const rect = computeReferenceAreaRect(
|
|
||||||
baseOptions({
|
|
||||||
y1: 150,
|
|
||||||
y2: 250,
|
|
||||||
ifOverflow: "discard",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert.equal(rect, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not clamp when ifOverflow is visible", () => {
|
|
||||||
const rect = computeReferenceAreaRect(
|
|
||||||
baseOptions({
|
|
||||||
y1: 150,
|
|
||||||
y2: 250,
|
|
||||||
ifOverflow: "visible",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert.deepEqual(rect, { x: 0, y: -50, width: 400, height: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for zero plot size", () => {
|
|
||||||
assert.equal(
|
|
||||||
computeReferenceAreaRect(
|
|
||||||
baseOptions({ innerWidth: 0, innerHeight: 200, y1: 10, y2: 20 })
|
|
||||||
),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveReferenceDataRange", () => {
|
|
||||||
it("returns inclusive bounds between y1 and y2", () => {
|
|
||||||
assert.deepEqual(resolveReferenceDataRange(160, 220, [0, 300]), [160, 220]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extends to domain edges when y bounds are omitted", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveReferenceDataRange(undefined, 220, [0, 300]),
|
|
||||||
[0, 220]
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveReferenceDataRange(160, undefined, [0, 300]),
|
|
||||||
[160, 300]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||