Compare commits

...

12 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 214b714873 feat: wire visual rewrite workspace UI 2026-07-02 13:48:24 +03:00
DCCONSTRUCTIONS f9b240dbd1 feat: add SEO rewrite workspace contracts 2026-07-02 13:48:13 +03:00
DCCONSTRUCTIONS 4b46936d0b docs: refresh SEO farm architecture notes 2026-07-02 13:48:04 +03:00
DCCONSTRUCTIONS 9582380ba3 Simplify strategy PDF styling 2026-06-30 16:27:53 +03:00
DCCONSTRUCTIONS ccfcc24066 Fix SEO strategy decision contracts 2026-06-30 16:11:25 +03:00
DCCONSTRUCTIONS 07337970ca Add strategy workspace exports and glass menus 2026-06-30 15:19:05 +03:00
DCCONSTRUCTIONS a785dffdf7 Refine SEO phase strategy guardrails 2026-06-30 15:18:36 +03:00
DCCONSTRUCTIONS 0279b1de47 Fix phase runway list keys 2026-06-30 00:21:28 +03:00
DCCONSTRUCTIONS 8df64918f8 Add phase-specific SEO strategy runway 2026-06-30 00:15:53 +03:00
DCCONSTRUCTIONS 54398f86d7 Rework SEO analytics workspace UX 2026-06-29 23:35:52 +03:00
DCCONSTRUCTIONS 6ed1df6803 Add SEO AI evidence contracts 2026-06-29 23:34:52 +03:00
DCCONSTRUCTIONS 95eea435ac Vendor SEO chart kit and shell media 2026-06-29 23:34:11 +03:00
331 changed files with 88261 additions and 2454 deletions

View File

@ -126,9 +126,9 @@ Final Validation
input: workingText, keyword map, media map, approved media changes
output: validation report
Apply / Export
Patch / Export
input: approved text/media changes
output: changeset, updated target project, reports/export
output: patch artifact, changeset manifest, reports/export
```
## 4. Tool Boundaries
@ -231,11 +231,23 @@ Codex через `codex exec` используется для смысловых
- предложить alt/caption/filename.
```
Codex не должен напрямую менять target project до `Apply`.
Codex не должен напрямую менять target project. Любая правка проходит через patch artifact.
## 5. Apply Safety Rules
## 5. Patch Safety Rules
До ручного Apply target project считается read-only.
Исходники сайта всегда считаются read-only для редактора SEO Mode.
Рабочий контур:
```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:
@ -260,7 +272,7 @@ Codex не должен напрямую менять target project до `Apply
- публиковать на хостинг.
```
Перед Apply UI обязан показать:
Перед production patch-run UI обязан показать:
```text
- affected files;
@ -268,18 +280,21 @@ Codex не должен напрямую менять target project до `Apply
- media diff;
- filename rename plan;
- validation status;
- changeset summary.
- patch artifact path/name;
- changeset summary;
- patch-run command/status.
```
Apply должен идти транзакционно:
Production patch-run должен идти транзакционно:
```text
1. build changeset
2. run dry-run
3. backup affected files to object storage
4. apply text/media/filename changes through backend
5. verify references and rename results
6. rollback if any critical step fails
1. build patch artifact
2. build changeset manifest
3. run dry-run against copied/current source tree
4. backup affected files to object storage
5. run patch script against source tree only after explicit user confirmation
6. verify references and rename results
7. rollback if any critical step fails
```
## 5.1. Repeated Scan / Reconcile Rules

View File

@ -0,0 +1,108 @@
# 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.

View File

@ -31,9 +31,10 @@
- `USER_FLOW.md` — пользовательский сценарий.
- `IMPLEMENTATION_GUARDRAILS.md` — pipeline, границы инструментов и риски.
- `UI_UX_SPEC.md` — UI/UX экраны, состояния и safety-паттерны.
- `TASK_MANAGER.md` — поэтапный план реализации.
- `CHECK_OVERSEO_SPEC.md` — спецификация локальной проверки переспама.
Актуальные задачи, статусы и следующий маршрут ведутся в Ops-карточках проекта `NDC PLATFORM`.
## Зафиксированные архитектурные решения
- `Postgres` — основной источник истины.

View File

@ -191,10 +191,10 @@ For MVP we start with `local_folder`, but the application should keep the source
18. UI shows report and diff
|
v
19. User applies changes to target project
19. User materializes approved patch artifact
|
v
20. User can export archive/reports
20. User runs production patch-run or exports archive/reports
```
MVP works with existing code/text only:
@ -298,11 +298,12 @@ app/
-> ru-text status
-> media SEO status
Apply / Export
Patch / Export
-> affected files
-> changeset
-> patch artifact
-> changeset manifest
-> manual approval
-> apply to target project
-> production patch-run
-> zip target project
-> export reports
@ -795,14 +796,12 @@ extract text
v
changeset
|
+-- patch artifact near imported source / project storage
+-- dry-run
+-- backup affected files to object storage
+-- apply through backend
+-- explicit production patch-run
+-- verify references/renames
+-- 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.
@ -816,7 +815,8 @@ Related local docs:
- `CHECK_OVERSEO_SPEC.md`
- `IMPLEMENTATION_GUARDRAILS.md`
- `UI_UX_SPEC.md`
- `TASK_MANAGER.md`
Актуальные задачи и статусы разработки ведутся в Ops-карточках проекта `NDC PLATFORM`.
- 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

View File

@ -302,28 +302,30 @@ Codex получает структурированное задание:
Важно: `Apply` нельзя делать после media-правок без повторной финальной проверки.
### 3.14. Apply / Export
### 3.14. Patch / Export
Перед применением UI показывает:
Перед materialize/production patch-run UI показывает:
- affected files;
- affected sections;
- text diff;
- media diff;
- filename rename plan;
- patch artifact path/name;
- validation status.
Пользователь нажимает `Apply to target project`. После apply создаётся changeset и сохраняется история.
Пользователь подтверждает создание patch artifact. Исходники сайта при этом не меняются. Production меняется только отдельным явным patch-run шагом, когда пользователь подтверждает, что выбранный patch можно запускать по source tree.
Безопасный apply-пайплайн:
Безопасный patch-пайплайн:
```text
1. собрать changeset
2. сделать dry-run
3. сохранить backup affected files в object storage
4. применить изменения через backend
5. проверить rename/update references
6. если что-то упало, выполнить rollback
1. собрать patch artifact
2. собрать changeset manifest
3. сделать dry-run против текущего source tree
4. сохранить backup affected files в object storage
5. запустить patch script только после явного подтверждения production patch-run
6. проверить rename/update references
7. если что-то упало, выполнить rollback
```
Export:
@ -1018,4 +1020,5 @@ Media SEO не даёт забыть alt, poster, видео-контекст и
- `CHECK_OVERSEO_SPEC.md` — спецификация локального анти-переспам инструмента.
- `IMPLEMENTATION_GUARDRAILS.md` — порядок backend pipeline, границы инструментов и риски разработки.
- `UI_UX_SPEC.md` — обязательные UI/UX-экраны, компоненты, состояния и safety-паттерны.
- `TASK_MANAGER.md` — поэтапный план реализации с подзадачами и критериями готовности.
Актуальные задачи, статусы и критерии следующего этапа ведутся в Ops-карточках проекта `NDC PLATFORM`.

View File

@ -0,0 +1,79 @@
# 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, а не становится главной ценностью экрана.

File diff suppressed because it is too large Load Diff

View File

@ -694,9 +694,9 @@ Soft warnings:
- low confidence classification;
- stylistic warning.
### 5.14. Apply / Export
### 5.14. Patch / Export
Цель: показать все изменения и применить их только после подтверждения.
Цель: показать все изменения, материализовать patch artifact и запускать production patch-run только после отдельного подтверждения.
Обязательные элементы:
@ -708,12 +708,14 @@ Soft warnings:
- media diff;
- filename rename plan;
- validation status;
- patch artifact path/name;
- changeset summary;
- confirmation checkbox;
- Apply button;
- Build Patch button;
- Run Patch button;
- Export button.
Apply должен быть заблокирован, если:
Build Patch должен быть заблокирован, если:
- нет финальной проверки;
- есть hard errors;
@ -721,6 +723,13 @@ Apply должен быть заблокирован, если:
- pending changes пустые;
- пользователь не подтвердил changeset.
Run Patch должен быть заблокирован, если:
- patch artifact не создан;
- dry-run не прошёл;
- пользователь не подтвердил production patch-run;
- source tree изменился после dry-run.
Export:
- zip target project;

View File

@ -639,4 +639,4 @@ Projects
UI/UX-набор обязательных экранов, компонентов и состояний вынесен в `UI_UX_SPEC.md`.
Пошаговый план реализации с критериями готовности вынесен в `TASK_MANAGER.md`.
Актуальный пошаговый план, статусы и критерии готовности ведутся в Ops-карточках проекта `NDC PLATFORM`.

View File

@ -0,0 +1,21 @@
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.

View File

@ -0,0 +1,192 @@
![hero](https://github.com/bklit/bklit-ui/blob/main/apps/web/app/opengraph-image.png?raw=true)
<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
![Alt](https://repobeats.axiom.co/api/embed/c591b93fd9e7bfa8f4dc8bddb716699615ee5fde.svg "Repobeats analytics image")
## 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.

View File

@ -0,0 +1,35 @@
# 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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 995 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1000 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 675 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1001 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1018 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 994 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1014 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 988 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 500 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 988 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 499 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1005 KiB

View File

@ -0,0 +1,17 @@
{
"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
}
}

View File

@ -0,0 +1,2 @@
// biome-ignore lint/performance/noBarrelFile: ESLint config re-export
export { config as default } from "@bklitui/eslint-config/react-internal";

View File

@ -0,0 +1,81 @@
{
"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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,97 @@
"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>
);
}

View File

@ -0,0 +1,45 @@
"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>
);
}

View File

@ -0,0 +1,80 @@
"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;
}

View File

@ -0,0 +1,38 @@
"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>
);
}

View File

@ -0,0 +1,45 @@
"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>
);
}

View File

@ -0,0 +1,32 @@
"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;
}

View File

@ -0,0 +1,91 @@
"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>
);
}

View File

@ -0,0 +1,38 @@
"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>
);
}

View File

@ -0,0 +1,143 @@
"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 };
}

View File

@ -0,0 +1,45 @@
"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>
);
}

View File

@ -0,0 +1,80 @@
"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;
}

View File

@ -0,0 +1,80 @@
"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>
);
}

View File

@ -0,0 +1,38 @@
"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>
);
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,30 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,29 @@
"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>
)
}

View File

@ -0,0 +1,7 @@
// 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";

View File

@ -0,0 +1,27 @@
"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>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { ChoroplethChart, ChoroplethFeatureComponent, ChoroplethTooltip } from "./choropleth";

View File

@ -0,0 +1,23 @@
"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>
)
}

View File

@ -0,0 +1,8 @@
// 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";

View File

@ -0,0 +1,30 @@
"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>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { FunnelChart } from "./funnel-chart";

View File

@ -0,0 +1,21 @@
"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>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { Gauge } from "./gauge";

View File

@ -0,0 +1,22 @@
"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>
)
}

View File

@ -0,0 +1,11 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export {
HeatmapCells,
HeatmapChart,
HeatmapInteractionBoundary,
HeatmapInteractionProvider,
HeatmapLegend,
HeatmapTooltip,
HeatmapXAxis,
HeatmapYAxis,
} from "./heatmap";

View File

@ -0,0 +1,53 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,30 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,32 @@
"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>
)
}

View File

@ -0,0 +1,4 @@
// 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";

View File

@ -0,0 +1,26 @@
"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>
)
}

View File

@ -0,0 +1,13 @@
// 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";

View File

@ -0,0 +1,72 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,32 @@
"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>
)
}

View File

@ -0,0 +1,4 @@
// 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";

View File

@ -0,0 +1,26 @@
"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>
)
}

View File

@ -0,0 +1,2 @@
// biome-ignore-all lint/performance/noBarrelFile: v0 registry example barrel for shadcn install
export { SankeyChart, SankeyLink, SankeyNode, SankeyTooltip } from "./sankey";

View File

@ -0,0 +1,34 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
// 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";

View File

@ -0,0 +1,29 @@
"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>
)
}

View File

@ -0,0 +1,6 @@
/** 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";

View File

@ -0,0 +1,13 @@
"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>
);
}

View File

@ -0,0 +1,3 @@
/** 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";

View File

@ -0,0 +1,13 @@
"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>
);
}

View File

@ -0,0 +1,5 @@
/** 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";

View File

@ -0,0 +1,13 @@
"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>
);
}

View File

@ -0,0 +1,328 @@
/**
* 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).`
);

View File

@ -0,0 +1,520 @@
/**
* 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`);

View File

@ -0,0 +1,24 @@
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);

View File

@ -0,0 +1,169 @@
#!/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).`
);

View File

@ -0,0 +1,178 @@
#!/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).`
);

View File

@ -0,0 +1,139 @@
#!/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();

View File

@ -0,0 +1,21 @@
"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>
);
};

View File

@ -0,0 +1,27 @@
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>-&gt;</span>
</h2>
<p>{children}</p>
</a>
);
}

View File

@ -0,0 +1,116 @@
"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;

View File

@ -0,0 +1,38 @@
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);
});
});

View File

@ -0,0 +1,54 @@
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
});
});

View File

@ -0,0 +1,76 @@
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"));
});
});
});

View File

@ -0,0 +1,56 @@
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);
});
});

View File

@ -0,0 +1,69 @@
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 });
});
});

View File

@ -0,0 +1,17 @@
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);
});
});

View File

@ -0,0 +1,30 @@
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)`);
}
});
});

View File

@ -0,0 +1,55 @@
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);
});
});

View File

@ -0,0 +1,50 @@
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);
});
});

View File

@ -0,0 +1,102 @@
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]
);
});
});

Some files were not shown because too many files have changed in this diff Show More