diff --git a/dataweaver/AGENTS.md b/dataweaver/AGENTS.md index 17636a58..1d90668c 100644 --- a/dataweaver/AGENTS.md +++ b/dataweaver/AGENTS.md @@ -30,6 +30,7 @@ Run `pnpm lint` (and `pnpm build` for UI changes) before considering work done. - **TypeScript** follows the [Google TypeScript Style Guide](https://google.github.io/styleguide/tsguide.html), enforced via Biome (`biome.json`). - **CSS / SCSS** follow the [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html): Stylelint for `.scss` (`stylelint.config.mjs`), Biome for plain `.css`. - **File naming** — use `dash-case` for all Next.js routing within `apps/web/src/app` (route segments, `page.tsx`, dynamic params, etc.) and `snake_case` for every other file. +- **Category-first naming** — composed names lead with what the thing *is*, then what makes it specific: `card_chart` (not `chart_card`), `button_close`, `icon_arrow_right`. Applies to files, folders, component identifiers, and element-prefixed SCSS classes. See [`FRONTEND.md` §1.2](FRONTEND.md#12-naming--category-first). ## Frontend @@ -49,11 +50,14 @@ elements compose primitives); `foundations` wrap the whole tree from the root. `primitives/icons/*`. - **`elements/`** — generic, reusable, presentational building blocks composed from primitives (a button, a tabs control). Self-contained, no feature or - business logic, usable anywhere. _e.g._ `elements/button`. + business logic, usable anywhere. **Flat by default** (`elements/button.tsx` + + `button.module.scss` sit next to `card.tsx` + `card.module.scss`); promote to + a folder only when the element gains a sub-component used solely by it. See + [`FRONTEND.md` §1.1](FRONTEND.md#11-flat-vs-nested--avoid-early-nesting). - **`scopes/`** — feature- or page-scoped compositions that assemble primitives and elements into a specific view. A scope **owns its sub-components**: pieces used only by that scope live in its folder, not in `elements/`. _e.g._ - `scopes/page_home` and `scopes/tldraw`. + `scopes/page_home` and `scopes/atlas`. - **`foundations/`** — app-level infrastructure and cross-cutting providers / services that the rest of the tree depends on but that render little or no UI of their own: context providers, motion / scroll providers, analytics, global diff --git a/dataweaver/FRONTEND.md b/dataweaver/FRONTEND.md index e7ba7043..965e027c 100644 --- a/dataweaver/FRONTEND.md +++ b/dataweaver/FRONTEND.md @@ -26,9 +26,9 @@ enforced via `biome.json` and `stylelint.config.mjs`. | Component | Location | |---|---| | Primitive (wrapper over one platform / third-party concern) | `components/primitives/.tsx`, or grouped by category — e.g. `components/primitives/icons/.tsx` | -| Element (generic, reusable building block) | `components/elements//.tsx` + `.module.scss` | +| Element (generic, reusable building block) | flat by default: `components/elements/.tsx` + `.module.scss`. Promote to a folder only when sub-components appear (see "Flat vs. nested" below). | | Scope (feature- / page-scoped composition) | `components/scopes//.tsx` | -| Scope-local subcomponent (used only by that scope) | nested in the scope folder, e.g. `components/scopes/tldraw/card/card.tsx` | +| Scope-local subcomponent (used only by that scope) | nested in the scope folder, e.g. `components/scopes/atlas/card/card.tsx` | | Foundation (app-level provider / service / global embed) | `components/foundations/.tsx`, mounted once near the root | Layered low → high: **primitives → elements → scopes**, with **foundations** @@ -46,6 +46,61 @@ reuse and concern (one platform concern → primitive; reusable presentational U - Keep a component in the narrowest scope that owns it. A component used only inside one scope lives in that scope's folder, not in `elements/`. +### 1.1 Flat vs. nested — avoid early nesting + +Default to **flat**: a component is a pair of sibling files named after it, +sitting next to its peers. + +``` +elements/ + button.tsx + button.module.scss + card.tsx + card.module.scss +``` + +**Promote to a folder only when the component gains a sub-component used solely +by it.** The original pair keeps its name; the sub-component lives alongside. + +``` +elements/ + button/ + button.tsx + button.module.scss + icon.tsx # used only by button + icon.module.scss + card.tsx + card.module.scss +``` + +A sub-component that becomes reused outside its parent gets promoted out to its +own flat pair in `elements/` (or up to a `primitive`, depending on concern). +Don't create a folder "in anticipation" of future sub-components — wait until +the second file actually exists. Same rule applies inside `scopes/`: a scope is +always a folder (it owns its view), but its sub-components stay flat inside +that folder until one of *them* grows a child of its own. + +### 1.2 Naming — category first + +**Lead the name with what the thing *is*, then what makes it specific.** A +card that holds a chart is `card_chart`, not `chart_card`; a card that holds +text is `card_text`; a button that closes is `button_close`; an icon of an +arrow is `icon_arrow_right`. + +This trades a slightly less natural-sounding name for real DX wins: + +- **Sorted directory listings group by category** — all `card_*` sit together, + all `button_*` sit together, all `icon_*` sit together. Browsing the folder + reads like an index of what's available. +- **Editor fuzzy-find narrows by category** — typing `card` surfaces every + card variant; you don't have to remember the modifier first. +- **Imports stay parallel** — `import { CardChart } from …; import { CardText } + from …;` line up visually instead of scattering by adjective. + +Apply this everywhere a name is composed of a noun + modifier: file and folder +names, component identifiers (`CardChart`, not `ChartCard`), and the +element-prefixed SCSS classes in §3.2 (`.button-close`, `.icon-arrow-right`). + --- ## 2. TypeScript @@ -87,8 +142,7 @@ CSS Modules only (`*.module.scss`), imported as `import s from './x.module.scss' `~/styles/includes` (breakpoint / helper / z-index mixins) is **auto-injected** into every module via `next.config.ts` `additionalData` — do **not** re-`@use` -it. Only `@use` files that aren't part of includes (e.g. -`@use "~/styles/typography.module" as typography;`). +it. ### 3.1 Selectors & formatting @@ -109,7 +163,8 @@ it. Only `@use` files that aren't part of includes (e.g. `

{title}

`. Otherwise use a `-container` or an element-prefixed class. - **Multiple buttons / icons → element-prefixed kebab**: `.button-close`, - `.icon-arrow-right` (read left-to-right: "a button that closes"). + `.icon-arrow-right` (read left-to-right: "a button that closes"). Same + category-first rule as §1.2 — never `.close-button` / `.arrow-right-icon`. ### 3.3 Variants & state via `data-*` @@ -117,11 +172,11 @@ Drive visual variants and boolean state through `data-*` attributes on the container — never through className flags: ```tsx -
+
``` ```scss -.container[data-state="selected"] .card { … } +.container[data-is-loading="true"] .card { … } ``` ### 3.4 Design tokens diff --git a/dataweaver/apps/web/package.json b/dataweaver/apps/web/package.json index a292ada4..efa27c00 100644 --- a/dataweaver/apps/web/package.json +++ b/dataweaver/apps/web/package.json @@ -11,15 +11,16 @@ "clsx": "^2.1.1", "motion": "^12.38.0", "next": "^16.2.6", - "react-dom": "^19.2.6", "react": "^19.2.6", + "react-dom": "^19.2.6", + "recharts": "^3.8.1", "tldraw": "^5.0.1" }, "devDependencies": { "@package/tokens": "workspace:tokens", "@types/node": "^25.9.1", - "@types/react-dom": "^19.2.3", "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "^1.0.0", "sass": "^1.97.3" } diff --git a/dataweaver/apps/web/src/app/(atlas)/layout.tsx b/dataweaver/apps/web/src/app/(atlas)/layout.tsx new file mode 100644 index 00000000..00c36529 --- /dev/null +++ b/dataweaver/apps/web/src/app/(atlas)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react'; +import { AtlasProvider } from '~/components/scopes/atlas/atlas'; + +interface AtlasLayoutProps { + children: ReactNode; +} + +const AtlasLayout = ({ children }: AtlasLayoutProps) => { + return {children}; +}; + +export default AtlasLayout; diff --git a/dataweaver/apps/web/src/app/page.tsx b/dataweaver/apps/web/src/app/(atlas)/page.tsx similarity index 100% rename from dataweaver/apps/web/src/app/page.tsx rename to dataweaver/apps/web/src/app/(atlas)/page.tsx diff --git a/dataweaver/apps/web/src/app/layout.tsx b/dataweaver/apps/web/src/app/layout.tsx index c86d1df5..349d0dbc 100644 --- a/dataweaver/apps/web/src/app/layout.tsx +++ b/dataweaver/apps/web/src/app/layout.tsx @@ -3,13 +3,25 @@ import '~/styles/core.scss'; import type { ReactNode } from 'react'; import { MotionProvider } from '~/components/foundations/motion_provider'; -interface LayoutProps { +const FONT_URL = + 'https://fonts.googleapis.com/css?family=Google+Sans:400,500,700&display=swap&lang=en'; + +interface RootLayoutProps { children: ReactNode; } -const RootLayout = ({ children }: LayoutProps) => { +const RootLayout = ({ children }: RootLayoutProps) => { return ( + + + + +
{children}
diff --git a/dataweaver/apps/web/src/components/elements/button.module.scss b/dataweaver/apps/web/src/components/elements/button.module.scss new file mode 100644 index 00000000..a1f36f3f --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/button.module.scss @@ -0,0 +1,76 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + color: rgb(var(--color-button-content)); + background: rgb(var(--color-button-base)); + + &[data-shape="pill"] { + height: 28px; + padding-inline: 12px 16px; + border-radius: 14px; + + &[data-size="small"] { + column-gap: 2px; + height: 28px; + padding-inline: 12px 16px; + border-radius: 14px; + } + + &[data-size="large"] { + column-gap: 6px; + height: 46px; + padding-inline: 22px 24px; + border-radius: 23px; + } + } + + &[data-shape="square"] { + &[data-size="small"] { + width: 28px; + height: 28px; + border-radius: 14px; + } + + &[data-size="large"] { + width: 40px; + height: 40px; + border-radius: 20px; + } + } + + @include prefers-motion { + transition-timing-function: $ease-linear; + transition-duration: 0.2s; + transition-property: color, background; + } + + // TODO: Implement disabled style + &[disabled] { + cursor: not-allowed; + opacity: 0.4; + } + + &:not([disabled]) { + @include hover { + color: rgb(var(--color-button-content-hover)); + background: rgb(var(--color-button-base-hover)); + } + } +} + +.icon { + flex-shrink: 0; + width: 24px; + height: 24px; +} + +.children { + [data-size="small"] & { + @include type-label-small; + } + + [data-size="large"] & { + @include type-label-large; + } +} diff --git a/dataweaver/apps/web/src/components/elements/button.tsx b/dataweaver/apps/web/src/components/elements/button.tsx new file mode 100644 index 00000000..3aa99fc2 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/button.tsx @@ -0,0 +1,69 @@ +import type { ComponentPropsWithRef, ComponentType } from 'react'; +import { mergeClassNames } from '~/functions/merge_class_names'; +import { mergeStyles } from '~/functions/merge_styles'; +import s from './button.module.scss'; + +interface WithIconOnly { + icon: ComponentType>; + 'aria-label': string; + children?: never; +} + +interface WithChildrenAndIcon { + icon: ComponentType>; + children: React.ReactNode; +} + +interface ColorScheme { + base: string; + 'base-hover': string; + content: string; + 'content-hover': string; +} + +type ButtonProps = { + size: 'small' | 'large'; + + /** If left `undefined`, the button will use the default app color scheme. */ + colorScheme?: ColorScheme; + + /** @default false */ + isDisabled?: boolean; +} & Omit, 'disabled' | 'children'> & + (WithIconOnly | WithChildrenAndIcon); + +export const Button = ({ + icon: Icon, + children, + size, + colorScheme, + isDisabled = false, + ...rest +}: ButtonProps) => { + const hasChildren = Boolean(children); + const shape = hasChildren ? 'pill' : 'square'; + + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/base.module.scss b/dataweaver/apps/web/src/components/elements/card/base.module.scss new file mode 100644 index 00000000..62ff1521 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/base.module.scss @@ -0,0 +1,75 @@ +.container { + --actions-height: 48px; + --corner-size: 16px; + --border-thickness: 2px; + + display: grid; + height: 100%; +} + +.actions-container { + display: flex; + grid-area: 1 / 1; + gap: 6px; + align-items: center; + justify-content: center; + height: calc(var(--actions-height) + var(--corner-size)); + padding-bottom: calc(var(--corner-size) - var(--border-thickness)); + margin: var(--actions-height) 2px 0; + background: rgb(var(--color-card-base-selected)); + border-radius: var(--corner-size) var(--corner-size) 0 0; + box-shadow: + 0 1px 3px 1px rgb(var(--color-shadow) / 15%), + 0 1px 2px rgb(var(--color-shadow) / 30%); + + @include prefers-motion { + transition: transform 0.5s $ease-out; + } + + [data-is-selected="true"] & { + transform: translateY(calc(var(--actions-height) * -1)); + } +} + +.children-container { + display: flex; + flex-direction: column; + grid-area: 1 / 1; + height: fit-content; + max-height: 100%; + overflow-y: auto; + background: rgb(var(--color-card-base)); + border: var(--border-thickness) solid transparent; + border-radius: var(--corner-size); + box-shadow: + 0 1px 3px 1px rgb(var(--color-shadow) / 15%), + 0 1px 2px rgb(var(--color-shadow) / 30%); + + @include prefers-motion { + transition: + transform 0.5s $ease-out, + border-color 0.5s $ease-out; + } + + [data-is-selected="true"] & { + border-color: rgb(var(--color-card-base-selected)); + transform: translateY(var(--actions-height)); + } +} + +.content { + display: flex; + flex-shrink: 0; + flex-direction: column; + padding: 28px; +} + +.footer { + flex-shrink: 0; + padding: 0 28px 18px; + + [data-loading="true"] & { + visibility: hidden; + pointer-events: none; + } +} diff --git a/dataweaver/apps/web/src/components/elements/card/base.tsx b/dataweaver/apps/web/src/components/elements/card/base.tsx new file mode 100644 index 00000000..94ef22b5 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/base.tsx @@ -0,0 +1,86 @@ +'use client'; + +import type { ComponentPropsWithRef, ComponentType, ReactNode } from 'react'; +import { Button } from '~/components/elements/button'; +import { useCachedResizeValues } from '~/hooks/use_cached_resize_values'; +import s from './base.module.scss'; + +/** The card's two orthogonal, independently-settable states. */ +export interface CardState { + isLoading: boolean; + isSelected: boolean; +} + +interface CardAction { + icon: ComponentType>; + label: string; + onClick?: () => void; + + /** @default false */ + isDisabled?: boolean; +} + +interface CardProps extends CardState { + actions: CardAction[]; + content: ReactNode; + + /** **Note**: This isn't shown while `isLoading`. */ + footer?: ReactNode; +} + +export const CardBase = ({ + isLoading, + isSelected, + actions, + content, + footer, +}: CardProps) => { + const getCachedCanScroll = useCachedResizeValues((element: HTMLElement) => { + return element.scrollHeight > element.clientHeight; + }); + + return ( +
+
+ {actions.map((action, index) => ( +
+ +
{ + if (getCachedCanScroll(false, event.currentTarget)) { + event.stopPropagation(); + } + }} + > +
{content}
+
+ {footer} +
+
+
+ ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss new file mode 100644 index 00000000..e5640fdc --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss @@ -0,0 +1,15 @@ +.header-container { + display: flex; + flex-direction: column; + row-gap: 8px; + margin-bottom: 16px; + color: rgb(var(--color-card-content)); +} + +.title { + @include type-title; +} + +.description { + @include type-body; +} diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx new file mode 100644 index 00000000..f45cf1ff --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx @@ -0,0 +1,66 @@ +'use client'; + +import type { CardState } from '~/components/elements/card/base'; +import { Skeleton } from '~/components/elements/skeleton'; +import { IconLineGraph } from '~/components/primitives/icons/line_graph'; +import { IconTable } from '~/components/primitives/icons/table'; +import s from './chart.module.scss'; +import { ConditionalTabs } from './conditional_tabs'; +import { type ChartDatum, DataChartLine } from './data_chart_line'; +import { DataTable } from './data_table'; + +// TODO: Get dynamically instead of hard coding here +const CHART_WIDTH = 356; +const CHART_HEIGHT = 200; + +interface CardChartProps extends Pick { + title?: string; + description?: string; + + // TODO: Atm data rendered within the card is very specific to the emissions + // dataset. Let's make it more generic once we have real data to work with + data?: ChartDatum[]; +} + +export const CardChart = ({ + isLoading, + data, + title, + description, +}: CardChartProps) => { + return ( + <> + {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + + {isLoading || !data ? ( + + ) : ( + + ), + }, + { + icon: IconTable, + label: 'Table', + children: , + }, + ]} + /> + )} + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.module.scss new file mode 100644 index 00000000..98631e0a --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.module.scss @@ -0,0 +1,39 @@ +.tabs-container { + display: flex; + margin-bottom: 16px; + border-bottom: 1px solid rgb(var(--color-card-content-subtle)); +} + +.tab { + @include type-label-small; + + display: flex; + gap: 4px; + align-items: center; + padding: 4px 12px 4px 8px; + color: rgb(var(--color-card-content-muted)); + border-bottom: 1px solid transparent; + + @include prefers-motion { + transition-timing-function: $ease-linear; + transition-duration: 0.2s; + transition-property: color, border-bottom-color; + } + + &[data-is-active="true"] { + color: rgb(var(--color-card-base-selected)); + border-bottom-color: rgb(var(--color-card-base-selected)); + } + + @include hover { + &[data-is-active="false"] { + color: rgb(var(--color-card-base-selected)); + } + } +} + +.icon { + flex-shrink: 0; + width: 24px; + height: 24px; +} diff --git a/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.tsx b/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.tsx new file mode 100644 index 00000000..44c5eb21 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.tsx @@ -0,0 +1,75 @@ +import { AnimatePresence, m } from 'motion/react'; +import type { ComponentPropsWithRef, ComponentType, ReactNode } from 'react'; +import { useId, useState } from 'react'; +import s from './conditional_tabs.module.scss'; + +interface Tab { + icon: ComponentType>; + + /** **Note**: Make sure each label is unique as it's used as key. */ + label: string; + children: ReactNode; +} + +interface ConditionalTabsProps { + tabs: Tab[]; +} + +// TODO: Animate line between tab changes +export const ConditionalTabs = ({ tabs }: ConditionalTabsProps) => { + const baseId = useId(); + + const [activeIndex, setActiveIndex] = useState(0); + + const tabId = (index: number) => `${baseId}-tab-${index}`; + + const panelId = (index: number) => `${baseId}-tabpanel-${index}`; + + const activeTab = tabs[activeIndex]; + + return ( + <> +
+ {tabs.map((tab, index) => { + const isActive = index === activeIndex; + + return ( + + ); + })} +
+ + + {activeTab && ( + + {activeTab.children} + + )} + + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/data_chart_line.tsx b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_line.tsx new file mode 100644 index 00000000..79a8fa5c --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_line.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { COLORS } from '@package/tokens/ts'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; + +export interface ChartDatum { + year: number; + emissions: number; +} + +const LINE_COLOR = `rgb(${COLORS['card-base-selected']})`; +const GRID_COLOR = `rgb(${COLORS['surface-decorator']})`; +const AXIS_COLOR = `rgb(${COLORS['card-content-muted']})`; + +interface ChartProps { + data: ChartDatum[]; + width: number; + height: number; +} + +export const DataChartLine = ({ data, width, height }: ChartProps) => { + return ( + + + + + + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/data_table.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/data_table.module.scss new file mode 100644 index 00000000..2ee2f0dc --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_table.module.scss @@ -0,0 +1,11 @@ +.table { + @include type-label-small; + + width: 100%; + text-align: left; +} + +.table th, +.table td { + padding: 4px 8px; +} diff --git a/dataweaver/apps/web/src/components/elements/card/chart/data_table.tsx b/dataweaver/apps/web/src/components/elements/card/chart/data_table.tsx new file mode 100644 index 00000000..88edee2d --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_table.tsx @@ -0,0 +1,28 @@ +import type { ChartDatum } from './data_chart_line'; +import s from './data_table.module.scss'; + +interface DataTableProps { + data: ChartDatum[]; +} + +// TODO: This is temporary - either style it or render using recharts +export const DataTable = ({ data }: DataTableProps) => { + return ( + + + + + + + + + {data.map(({ year, emissions }) => ( + + + + + ))} + +
YearEmissions (Mt CO₂e)
{year}{emissions}
+ ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/index.ts b/dataweaver/apps/web/src/components/elements/card/index.ts new file mode 100644 index 00000000..f9c7abc8 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/index.ts @@ -0,0 +1,9 @@ +import { CardBase } from './base'; +import { CardChart } from './chart/chart'; +import { CardText } from './text'; + +export const Card = { + Base: CardBase, + Text: CardText, + Chart: CardChart, +} as const; diff --git a/dataweaver/apps/web/src/components/elements/card/text.module.scss b/dataweaver/apps/web/src/components/elements/card/text.module.scss new file mode 100644 index 00000000..453f6669 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/text.module.scss @@ -0,0 +1,12 @@ +.title { + @include type-title; + + margin-bottom: 16px; + color: rgb(var(--color-card-content)); +} + +.body { + @include type-body; + + color: rgb(var(--color-card-content)); +} diff --git a/dataweaver/apps/web/src/components/elements/card/text.tsx b/dataweaver/apps/web/src/components/elements/card/text.tsx new file mode 100644 index 00000000..293a1ca0 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/text.tsx @@ -0,0 +1,18 @@ +import type { CardState } from '~/components/elements/card/base'; +import { Skeleton } from '~/components/elements/skeleton'; +import s from './text.module.scss'; + +interface CardTextProps extends Pick { + title?: string; + body?: string; +} + +export const CardText = ({ title, body, isLoading }: CardTextProps) => { + return ( + <> + {title &&

{title}

} + + {isLoading ? : body &&
{body}
} + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/skeleton.module.scss b/dataweaver/apps/web/src/components/elements/skeleton.module.scss new file mode 100644 index 00000000..791e9800 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/skeleton.module.scss @@ -0,0 +1,21 @@ +.container { + display: flex; + flex-direction: column; + gap: 4px; +} + +@keyframes skeleton-pulse { + 50% { + opacity: 0.45; + } +} + +.line { + height: 18px; + background-color: rgb(var(--color-card-skeleton)); + border-radius: 8px; + + @include prefers-motion { + animation: skeleton-pulse 1.5s $ease-in-out infinite; + } +} diff --git a/dataweaver/apps/web/src/components/elements/skeleton.tsx b/dataweaver/apps/web/src/components/elements/skeleton.tsx new file mode 100644 index 00000000..6e1b0640 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/skeleton.tsx @@ -0,0 +1,44 @@ +import { ScreenReaderOnly } from '~/components/primitives/screen_reader'; +import s from './skeleton.module.scss'; + +/** Skeleton line widths (% of the body) mirroring the loading design. */ +const DEFAULT_LINE_WIDTHS = [100, 84, 92, 72]; + +interface SkeletonProps { + /** + * One line per entry; each value is the line's width as a % of the row. + * + * @default '[100, 84, 92, 72]' + */ + widths?: number[]; + + /** + * Announced to assistive tech while content loads. + * + * @default 'Loading…' + */ + label?: string; +} + +/** Animated placeholder lines shown in place of content while it loads. */ +export const Skeleton = ({ + widths = DEFAULT_LINE_WIDTHS, + label = 'Loading…', +}: SkeletonProps) => { + return ( +
+ {widths.map((width, index) => ( + + ))} + + {label} +
+ ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx b/dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx new file mode 100644 index 00000000..b2653299 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/bar_chart.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconBarChart = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/cursor.tsx b/dataweaver/apps/web/src/components/primitives/icons/cursor.tsx new file mode 100644 index 00000000..c926db21 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/cursor.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconCursor = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/delete.tsx b/dataweaver/apps/web/src/components/primitives/icons/delete.tsx new file mode 100644 index 00000000..afe7b1ae --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/delete.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconDelete = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/export.tsx b/dataweaver/apps/web/src/components/primitives/icons/export.tsx new file mode 100644 index 00000000..334f7e3b --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/export.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconExport = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/hand.tsx b/dataweaver/apps/web/src/components/primitives/icons/hand.tsx new file mode 100644 index 00000000..034b2810 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/hand.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconHand = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/insert_text.tsx b/dataweaver/apps/web/src/components/primitives/icons/insert_text.tsx new file mode 100644 index 00000000..e80dfb22 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/insert_text.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconInsertText = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx b/dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx new file mode 100644 index 00000000..c107509e --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/line_graph.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconLineGraph = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/minus.tsx b/dataweaver/apps/web/src/components/primitives/icons/minus.tsx new file mode 100644 index 00000000..a804516b --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/minus.tsx @@ -0,0 +1,15 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconMinus = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/pencil.tsx b/dataweaver/apps/web/src/components/primitives/icons/pencil.tsx new file mode 100644 index 00000000..d3e1e415 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/pencil.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconPencil = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/plus.tsx b/dataweaver/apps/web/src/components/primitives/icons/plus.tsx new file mode 100644 index 00000000..f5ac2508 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/plus.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconPlus = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/icons/table.tsx b/dataweaver/apps/web/src/components/primitives/icons/table.tsx new file mode 100644 index 00000000..ec134d14 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/icons/table.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; + +export const IconTable = (props: ComponentPropsWithRef<'svg'>) => { + return ( + + ); +}; diff --git a/dataweaver/apps/web/src/components/primitives/screen_reader.module.scss b/dataweaver/apps/web/src/components/primitives/screen_reader.module.scss new file mode 100644 index 00000000..457a2abe --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/screen_reader.module.scss @@ -0,0 +1,3 @@ +.screen-reader-only { + @include screen-reader-only; +} diff --git a/dataweaver/apps/web/src/components/primitives/screen_reader.tsx b/dataweaver/apps/web/src/components/primitives/screen_reader.tsx new file mode 100644 index 00000000..46d37bc0 --- /dev/null +++ b/dataweaver/apps/web/src/components/primitives/screen_reader.tsx @@ -0,0 +1,18 @@ +import type { ComponentPropsWithRef } from 'react'; +import s from './screen_reader.module.scss'; + +type ScreenReaderOnlyProps = Omit< + WithRequired, 'children'>, + 'className' +>; + +export const ScreenReaderOnly = ({ + children, + ...rest +}: ScreenReaderOnlyProps) => { + return ( + + {children} + + ); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/atlas.module.scss b/dataweaver/apps/web/src/components/scopes/atlas/atlas.module.scss new file mode 100644 index 00000000..d5a23cb7 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/atlas.module.scss @@ -0,0 +1,20 @@ +@import "tldraw/tldraw.css" layer(primitive); + +.tldraw { + --tl-color-background: transparent; + --tl-color-selected: rgb(var(--color-control-accent)); + --tl-color-selected-contrast: rgb(var(--color-control-accent-content)); + + position: fixed; + inset: 0; + + // Ensure all nested layers appear from a base of 0 and not above app content + z-index: $z-index-content; + background-color: rgb(var(--color-surface-base)); + + // The text tool's font family + colour come from the tldraw theme (see + // atlas.tsx), but its base weight lives in a tldraw constant. Force here + :global(.tl-rich-text) { + font-weight: 500; + } +} diff --git a/dataweaver/apps/web/src/components/scopes/atlas/atlas.tsx b/dataweaver/apps/web/src/components/scopes/atlas/atlas.tsx new file mode 100644 index 00000000..9b04ab5f --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/atlas.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { COLORS } from '@package/tokens/ts'; +import { type ReactNode, useCallback, useRef } from 'react'; +import { createShapeId, type Editor, Tldraw } from 'tldraw'; +import s from './atlas.module.scss'; +import { ATLAS_COMPONENTS, ATLAS_SHAPES, ZOOM_STEPS } from './config'; +import { contentToShape, gridPosition } from './helpers'; +import { type Atlas, AtlasContext } from './use_atlas'; + +type Operation = (editor: Editor) => void; + +interface AtlasProviderProps { + children: ReactNode; +} + +export const AtlasProvider = ({ children }: AtlasProviderProps) => { + const editorRef = useRef(null); + const pendingShapesQueueRef = useRef<((editor: Editor) => void)[]>([]); + + // TODO: For now using count for positioning - we likely want to use a smarter + // approach that accounts for deleted content and doesn't rely on order of + // addition, etc later once we hook up to real data + const countRef = useRef(0); + + const withEditor = useCallback((operation: Operation) => { + const editor = editorRef.current; + + // If we have the editor available - perform the operation immediately + if (editor) operation(editor); + // Otherwise queue it up for when the editor is ready (e.g. once mounted) + else pendingShapesQueueRef.current.push(operation); + }, []); + + const add: Atlas['add'] = useCallback( + (content) => { + const shapeId = createShapeId(); + + // First: Create the shape with any immediately available content + withEditor((e) => { + e.createShape( + contentToShape(shapeId, content, gridPosition(countRef.current)), + ); + + // Increment count for next shape's position + countRef.current++; + }); + + // Then: Return handle that allows for future updates to the shape as more + // content becomes available, or for the shape to be removed + return { + id: shapeId, + variant: content.variant, + update(props) { + withEditor((e) => + e.updateShape({ id: shapeId, type: 'card', props }), + ); + }, + remove() { + withEditor((e) => e.deleteShapes([shapeId])); + }, + }; + }, + [withEditor], + ); + + const mounted = useCallback((editor: Editor) => { + // Render the dot grid (camera-tracked via the 'Grid' component slot) + editor.updateInstanceState({ isGridMode: true }); + + // Define camera zoom levels + editor.setCameraOptions({ zoomSteps: [...ZOOM_STEPS] }); + + // Style built-in theme to match our design system + editor.updateThemes((themes) => { + // Text styles + themes.default.fonts.draw.fontFamily = '"Google Sans", sans-serif'; + themes.default.fontSize = 24; + themes.default.lineHeight = 1.25; + themes.default.colors.light.black.solid = + 'rgb(var(--color-surface-content))'; + + // Selection style (Note: This is drawn in Canvas so needs RGB vs CSS var) + themes.default.colors.light.selectionStroke = `rgb(${COLORS['control-accent']})`; + + return themes; + }); + + editorRef.current = editor; + countRef.current = 0; + + // If there were any operations queued up before the editor was ready, + // run them now that we've mounted + const queued = pendingShapesQueueRef.current; + pendingShapesQueueRef.current = []; + for (const operation of queued) operation(editor); + }, []); + + return ( + + + {children} + + ); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/controls.module.scss b/dataweaver/apps/web/src/components/scopes/atlas/components/controls.module.scss new file mode 100644 index 00000000..3b1b6d45 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/controls.module.scss @@ -0,0 +1,60 @@ +.container { + position: absolute; + inset: 0; + pointer-events: none; +} + +.controls-container { + position: absolute; + top: 16px; + right: 16px; + display: flex; + gap: 17px; + align-items: center; + pointer-events: auto; + + .zoom-container { + display: flex; + gap: 4px; + align-items: center; + height: 46px; + padding: 0 8px; + color: rgb(var(--color-control-accent)); + background: rgb(var(--color-control-surface)); + border-radius: 23px; + box-shadow: + 0 1px 3px 1px rgb(var(--color-shadow) / 15%), + 0 1px 2px rgb(var(--color-shadow) / 30%); + + .zoom-value { + @include type-label-large; + + width: 56px; + text-align: center; + } + } + + .button-export { + box-shadow: + 0 1px 3px 1px rgb(var(--color-shadow) / 15%), + 0 1px 2px rgb(var(--color-shadow) / 30%); + } +} + +.tools-container { + position: absolute; + top: 200px; + right: 16px; + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + width: 58px; + padding: 8px; + pointer-events: auto; + background: rgb(var(--color-control-surface)); + border-radius: 29px; + box-shadow: + 0 1px 3px 1px rgb(var(--color-shadow) / 15%), + 0 1px 2px rgb(var(--color-shadow) / 30%); +} diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/controls.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/controls.tsx new file mode 100644 index 00000000..02f72b3c --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/controls.tsx @@ -0,0 +1,140 @@ +import { useEditor, useValue } from 'tldraw'; +import { Button } from '~/components/elements/button'; +import { IconCursor } from '~/components/primitives/icons/cursor'; +import { IconExport } from '~/components/primitives/icons/export'; +import { IconHand } from '~/components/primitives/icons/hand'; +import { IconInsertText } from '~/components/primitives/icons/insert_text'; +import { IconMinus } from '~/components/primitives/icons/minus'; +import { IconPlus } from '~/components/primitives/icons/plus'; +import { + MAX_ZOOM, + MIN_ZOOM, + ZOOM_DISPLAY_RANGE, +} from '~/components/scopes/atlas/config'; +import { mapRange } from '~/functions/map_range'; +import s from './controls.module.scss'; + +const TOOLS = { + select: { label: 'Select', Icon: IconCursor }, + hand: { label: 'Pan', Icon: IconHand }, + text: { label: 'Text', Icon: IconInsertText }, +} as const; + +type ToolName = keyof typeof TOOLS; + +/** Type guard to ensure a given string is a valid ToolName. */ +const isToolName = (name: string): name is ToolName => name in TOOLS; + +const BUTTON_EXPORT_COLOR_SCHEME = { + base: 'var(--color-control-surface)', + 'base-hover': 'var(--color-control-surface-hover)', + content: 'var(--color-control-accent)', + 'content-hover': 'var(--color-control-accent)', +}; + +const BUTTON_ZOOM_COLOR_SCHEME = { + base: 'transparent', + 'base-hover': 'var(--color-control-surface-hover)', + content: 'var(--color-control-accent)', + 'content-hover': 'var(--color-control-accent)', +}; + +const BUTTON_TOOL_COLOR_SCHEME_SELECTED = { + base: 'var(--color-control-accent)', + 'base-hover': 'var(--color-control-accent)', + content: 'var(--color-control-accent-content)', + 'content-hover': 'var(--color-control-accent-content)', +}; + +const BUTTON_TOOL_COLOR_SCHEME_INACTIVE = { + base: 'transparent', + 'base-hover': 'var(--color-control-surface-hover)', + content: 'var(--color-control-content)', + 'content-hover': 'var(--color-control-content)', +}; + +/** + * Editor-bound wrapper rendered through tldraw's `InFrontOfTheCanvas` slot so + * it can read live tool / zoom state via `useEditor`. + */ +export const Controls = () => { + const editor = useEditor(); + + const tool = useValue('tool', () => editor.getCurrentToolId(), [editor]); + const zoom = useValue('zoom', () => editor.getZoomLevel(), [editor]); + + // Here we map the tldraw tool to one of our supported ones. If it's not found + // we fallback to default 'select' tool + const activeToolName: ToolName = isToolName(tool) ? tool : 'select'; + + // Rescale the actual zoom from tldraw's enforced min / max (the first and + // last zoom steps) onto the value we display + const zoomDisplay = Math.round( + mapRange( + zoom, + MIN_ZOOM, + MAX_ZOOM, + ZOOM_DISPLAY_RANGE[0], + ZOOM_DISPLAY_RANGE[1], + ), + ); + + return ( +
+
+
+
+ + +
+ +
+ {Object.entries(TOOLS).map(([name, tool]) => ( +
+
+ ); +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/grid.module.scss b/dataweaver/apps/web/src/components/scopes/atlas/components/grid.module.scss new file mode 100644 index 00000000..6d179233 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/grid.module.scss @@ -0,0 +1,11 @@ +@import "tldraw/tldraw.css" layer(primitive); + +.grid { + position: absolute; + inset: 0; + background-image: radial-gradient( + rgb(var(--color-surface-decorator)) 1px, + transparent 1px + ); + background-size: 20px 20px; +} diff --git a/dataweaver/apps/web/src/components/scopes/atlas/components/grid.tsx b/dataweaver/apps/web/src/components/scopes/atlas/components/grid.tsx new file mode 100644 index 00000000..abb4ef40 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/components/grid.tsx @@ -0,0 +1,15 @@ +import type { TLGridProps } from 'tldraw'; +import s from './grid.module.scss'; + +/** + * Dot grid that tracks the camera. TLDraw transforms only its inner canvas + * layer - so the grid is rendered through this slot (handed the live camera) + * rather than as a static background on the outer container. Dots pan with the + * camera ('x * z', 'y * z') but keep a fixed screen size (no zoom scaling). + */ +export const Grid = ({ x, y, z }: TLGridProps) => ( +
+); diff --git a/dataweaver/apps/web/src/components/scopes/atlas/config.ts b/dataweaver/apps/web/src/components/scopes/atlas/config.ts new file mode 100644 index 00000000..37a1dd32 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/config.ts @@ -0,0 +1,49 @@ +import type { TLComponents } from 'tldraw'; +import { Controls } from './components/controls'; +import { Grid } from './components/grid'; +import { ShapeCardUtil } from './shapes/card'; + +/** + * Component overrides for tldraw - this allows us to inject our own React + * components into the editor's UI via given 'slots'. + */ +export const ATLAS_COMPONENTS = { + Grid, + InFrontOfTheCanvas: Controls, +} as const satisfies TLComponents; + +/** The shapes that the Atlas supports. */ +export const ATLAS_SHAPES = [ShapeCardUtil] as const; + +/** Atlas min zoom level. This is the minimum zoom enforced by tldraw. */ +export const MIN_ZOOM = 0.25; + +/** Atlas max zoom level. This is the maximum zoom enforced by tldraw. */ +export const MAX_ZOOM = 3.25; + +/** The range the actual zoom is rescaled onto for display in the controls. */ +export const ZOOM_DISPLAY_RANGE = [0, 200] as const; + +/** + * How much the displayed zoom value changes per zoom in / out step. We divide + * the display range evenly (i.e. here 200 / 20 = 10 steps). + */ +const ZOOM_DISPLAY_STEP = 20; + +/** + * The discrete zoom levels the controls step through, generated so they stay + * evenly spaced across [MIN_ZOOM, MAX_ZOOM] — that even spacing is what makes + * the displayed value increment by a constant `ZOOM_DISPLAY_STEP`. + */ +const ZOOM_STEP_COUNT = + (ZOOM_DISPLAY_RANGE[1] - ZOOM_DISPLAY_RANGE[0]) / ZOOM_DISPLAY_STEP; + +/** + * The discrete zoom levels the controls step through, generated so they stay + * evenly spaced across [MIN_ZOOM, MAX_ZOOM] — that even spacing is what makes + * the displayed value increment by a constant `ZOOM_DISPLAY_STEP`. + */ +export const ZOOM_STEPS: readonly number[] = Array.from( + { length: ZOOM_STEP_COUNT + 1 }, + (_, index) => MIN_ZOOM + (index * (MAX_ZOOM - MIN_ZOOM)) / ZOOM_STEP_COUNT, +); diff --git a/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts b/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts new file mode 100644 index 00000000..b67620a8 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts @@ -0,0 +1,102 @@ +import type { TLCreateShapePartial, TLShape, TLShapeId } from 'tldraw'; +import type { ChartDatum } from '~/components/elements/card/chart/data_chart_line'; + +export type CardVariant = 'text' | 'chart'; + +interface BaseContent { + isLoading?: boolean; + followUp?: string; +} + +interface TextContent extends BaseContent { + variant: 'text'; + title?: string; + body?: string; +} + +interface ChartContent extends BaseContent { + variant: 'chart'; + title?: string; + description?: string; + data?: ChartDatum[]; +} + +/** + * App-level description of a thing to mount on the canvas. The atlas only + * renders cards; the variant decides which content fields are valid. + */ +export type AtlasContent = TextContent | ChartContent; + +/** + * Flat view of every possible content field — variant-specific fields become + * optional. Used by the shape util, since tldraw stores props as a flat record. + */ +export type CardContentFields = Omit & + Omit; + +interface Position { + x: number; + y: number; +} + +const GRID = { + columns: 4, + stepX: 460, + stepY: 600, + originX: 120, + originY: 120, +} as const; + +/** A simple utility to get a new content's position based on index. */ +export const gridPosition = (index: number): Position => ({ + x: GRID.originX + (index % GRID.columns) * GRID.stepX, + y: GRID.originY + Math.floor(index / GRID.columns) * GRID.stepY, +}); + +// Per-variant default canvas footprint +const VARIANT_SIZE: Record = { + text: { w: 360, h: 440 }, + chart: { w: 420, h: 520 }, +}; + +export const contentToShape = ( + shapeId: TLShapeId, + content: AtlasContent, + position: Position, +): TLCreateShapePartial> => { + const baseProps = { + id: shapeId, + x: position.x, + y: position.y, + type: 'card' as const, + }; + + const shapeProps = { + ...VARIANT_SIZE[content.variant], + isLoading: content.isLoading ?? false, + followUp: content.followUp, + }; + + if (content.variant === 'chart') { + return { + ...baseProps, + props: { + ...shapeProps, + variant: 'chart', + title: content.title, + description: content.description, + data: content.data, + }, + }; + } + + return { + ...baseProps, + props: { + ...shapeProps, + variant: 'text', + title: content.title, + body: content.body, + }, + }; +}; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx new file mode 100644 index 00000000..f5c647f9 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx @@ -0,0 +1,139 @@ +import { + HTMLContainer, + type RecordProps, + Rectangle2d, + ShapeUtil, + T, + type TLShape, +} from 'tldraw'; +import { Button } from '~/components/elements/button'; +import { Card } from '~/components/elements/card'; +import { IconBarChart } from '~/components/primitives/icons/bar_chart'; +import { IconDelete } from '~/components/primitives/icons/delete'; +import { IconExport } from '~/components/primitives/icons/export'; +import { IconPencil } from '~/components/primitives/icons/pencil'; +import type { + CardContentFields, + CardVariant, +} from '~/components/scopes/atlas/helpers'; + +type ShapeCardProps = CardContentFields & { + w: number; + h: number; + variant: CardVariant; + isLoading: boolean; +}; + +// Register the custom shape within tldraw +declare module 'tldraw' { + interface TLGlobalShapePropsMap { + card: ShapeCardProps; + } +} + +type ShapeCard = TLShape<'card'>; + +export class ShapeCardUtil extends ShapeUtil { + static override type = 'card' as const; + + static override props: RecordProps = { + w: T.number, + h: T.number, + variant: T.literalEnum('text', 'chart'), + title: T.string.optional(), + description: T.string.optional(), + body: T.string.optional(), + data: T.arrayOf( + T.object({ year: T.number, emissions: T.number }), + ).optional(), + isLoading: T.boolean, + followUp: T.string.optional(), + }; + + override getDefaultProps = (): ShapeCardProps => { + return { w: 360, h: 440, variant: 'text', isLoading: false }; + }; + + override getGeometry = (shape: ShapeCard) => { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }); + }; + + #getActions = (shape: ShapeCard, isLoading: boolean) => { + const deleteAction = { + icon: IconDelete, + label: 'Delete', + onClick: () => this.editor.deleteShapes([shape.id]), + }; + + // TODO: Hook up action(s) once supported + const exportAction = { + icon: IconExport, + label: 'Export', + isDisabled: isLoading, + }; + + if (shape.props.variant === 'chart') { + return [ + { icon: IconBarChart, label: 'View chart', isDisabled: isLoading }, + exportAction, + deleteAction, + ]; + } + + return [exportAction, deleteAction]; + }; + + #renderContent = (shape: ShapeCard, isLoading: boolean) => { + const { variant, title, description, body, data } = shape.props; + + if (variant === 'chart') { + return ( + + ); + } + + return ; + }; + + override component = (shape: ShapeCard) => { + const { w, h, isLoading, followUp } = shape.props; + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id); + + return ( + + event.stopPropagation()} + > + {followUp} + + ) + } + /> + + ); + }; + + // Disable default TLDraw events + override canResize = () => false; + override hideSelectionBoundsFg = () => true; + override hideSelectionBoundsBg = () => true; + override getIndicatorPath = () => undefined; +} diff --git a/dataweaver/apps/web/src/components/scopes/atlas/use_atlas.ts b/dataweaver/apps/web/src/components/scopes/atlas/use_atlas.ts new file mode 100644 index 00000000..2f75f011 --- /dev/null +++ b/dataweaver/apps/web/src/components/scopes/atlas/use_atlas.ts @@ -0,0 +1,44 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { TLShapeId } from 'tldraw'; +import type { AtlasContent, CardVariant } from './helpers'; + +/** The content shape that corresponds to a given card variant. */ +type ContentForVariant = Extract< + AtlasContent, + { variant: TVariant } +>; + +/** + * Handle returned by `atlas.add(...)`. Use it to populate a card with real + * data as it arrives, or to remove the card from the canvas. The handle is + * typed against the variant passed to `add`, so updates can only set fields + * that belong to that variant. + */ +interface CardHandle { + readonly id: TLShapeId; + readonly variant: TVariant; + update(props: Partial, 'variant'>>): void; + remove(): void; +} + +/** Public atlas surface — what `useAtlas()` returns. */ +export interface Atlas { + add( + content: ContentForVariant, + ): CardHandle; +} + +/** @internal */ +export const AtlasContext = createContext(null); + +/** Read the atlas — must be used inside ``. */ +export const useAtlas = (): Atlas => { + const context = useContext(AtlasContext); + if (!context) { + throw new Error("'useAtlas' must be used within 'AtlasProvider'."); + } + + return context; +}; diff --git a/dataweaver/apps/web/src/components/scopes/page_home.tsx b/dataweaver/apps/web/src/components/scopes/page_home.tsx index b715a35f..f1f79f1c 100644 --- a/dataweaver/apps/web/src/components/scopes/page_home.tsx +++ b/dataweaver/apps/web/src/components/scopes/page_home.tsx @@ -1,12 +1,60 @@ 'use client'; -import dynamic from 'next/dynamic'; - -const Tldraw = dynamic( - () => import('./tldraw').then((module) => module.Tldraw), - { ssr: false }, -); +import { useEffect } from 'react'; +import { useAtlas } from './atlas/use_atlas'; export const PageHome = () => { - return ; + const atlas = useAtlas(); + + // TODO: This is only temporary to show case adding / update flow + useEffect(() => { + const cardText = atlas.add({ + variant: 'text', + title: 'Key insights when evaluating greenhouse gas emissions', + isLoading: true, + }); + + const cardChart = atlas.add({ + variant: 'chart', + title: 'Greenhouse gas emissions in Africa', + isLoading: true, + }); + + // Populate them as 'data arrives' + const cardTimeout = setTimeout(() => { + cardText.update({ + body: 'Emissions per capita remain low relative to other regions, energy access is the dominant driver, and land-use change accounts for a large share of the total.', + isLoading: false, + followUp: 'What are the key drivers of these trends?', + }); + }, 1500); + + const chartTimeout = setTimeout(() => { + cardChart.update({ + description: + 'The chart above tracks total GHG emissions across Africa over time.', + data: [ + { year: 2000, emissions: 820 }, + { year: 2003, emissions: 905 }, + { year: 2006, emissions: 980 }, + { year: 2009, emissions: 1045 }, + { year: 2012, emissions: 1180 }, + { year: 2015, emissions: 1260 }, + { year: 2018, emissions: 1390 }, + { year: 2021, emissions: 1470 }, + ], + isLoading: false, + followUp: 'What are the key drivers of these trends?', + }); + }, 2500); + + return () => { + clearTimeout(cardTimeout); + clearTimeout(chartTimeout); + cardText.remove(); + cardChart.remove(); + }; + }, [atlas]); + + return null; }; diff --git a/dataweaver/apps/web/src/components/scopes/tldraw.module.scss b/dataweaver/apps/web/src/components/scopes/tldraw.module.scss deleted file mode 100644 index 96a6c7e0..00000000 --- a/dataweaver/apps/web/src/components/scopes/tldraw.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import "tldraw/tldraw.css" layer(primitive); - -.canvas { - --tl-color-background: transparent; - - position: fixed; - inset: 0; - - // Ensure all nested layers appear from a base of 0 and not above app content - z-index: $z-index-content; - background-color: rgb(var(--color-surface-base)); - background-image: radial-gradient( - rgb(var(--color-surface-decorator)) 1px, - transparent 1px - ); - background-position: center center; - background-size: 20px 20px; -} diff --git a/dataweaver/apps/web/src/components/scopes/tldraw.tsx b/dataweaver/apps/web/src/components/scopes/tldraw.tsx deleted file mode 100644 index 33e381c8..00000000 --- a/dataweaver/apps/web/src/components/scopes/tldraw.tsx +++ /dev/null @@ -1,8 +0,0 @@ -'use client'; - -import { Tldraw as PrimitiveTldraw } from 'tldraw'; -import s from './tldraw.module.scss'; - -export const Tldraw = () => { - return ; -}; diff --git a/dataweaver/apps/web/src/functions/map_range.ts b/dataweaver/apps/web/src/functions/map_range.ts new file mode 100644 index 00000000..9feca60f --- /dev/null +++ b/dataweaver/apps/web/src/functions/map_range.ts @@ -0,0 +1,15 @@ +/** + * Linearly remap `value` from the input range `[inMin, inMax]` onto the output + * range `[outMin, outMax]`. Not clamped — values outside the input range map + * proportionally outside the output range. + * + * @example + * mapRange(100, 0, 800, 0, 200); // 25 + */ +export const mapRange = ( + value: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number, +) => outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin); diff --git a/dataweaver/apps/web/src/functions/merge_styles.ts b/dataweaver/apps/web/src/functions/merge_styles.ts new file mode 100644 index 00000000..bb53beb7 --- /dev/null +++ b/dataweaver/apps/web/src/functions/merge_styles.ts @@ -0,0 +1,29 @@ +import type { CSSProperties } from 'react'; + +/** + * Helper to merge multiple style objects into one, filtering out any invalid + * values to avoid overwriting existing styles when none is provided. + * + * @example + * const mergedStyles = mergeStyles( + * { color: 'red', aspectRatio: '16 / 9' }, + * { color: 'blue', aspectRatio: undefined }, + * ); + * + * // Result: { color: 'blue', aspectRatio: '16 / 9' } + */ +export const mergeStyles = (...styles: Array) => { + const merged: CSSProperties = {}; + + for (const style of styles) { + // Ignore if style isn't set + if (!style) continue; + + // Iterate over each style property and only assign it if it's defined + for (const [key, value] of Object.entries(style)) { + if (value !== undefined) merged[key as keyof CSSProperties] = value; + } + } + + return merged; +}; diff --git a/dataweaver/apps/web/src/hooks/use_cached_resize_values.ts b/dataweaver/apps/web/src/hooks/use_cached_resize_values.ts new file mode 100644 index 00000000..5bff2a9f --- /dev/null +++ b/dataweaver/apps/web/src/hooks/use_cached_resize_values.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import { Resize } from '~/utilities/resize'; + +/** + * Hook to cache values that depend on window resize. + * + * The key assumption is that cached values will not change unless the window + * size has changed, allowing for performance optimization by avoiding expensive + * recalculations. + * + * Under the hood we only attach a single resize listener no matter how many + * elements are using this hook for improved performance. + * + * @param callback A callback function that computes values to cache. + * @returns A getter where the first argument is an optional `invalidate` + * boolean. Pass `true` to force re-computation regardless of resize state. If + * additional arguments are passed they are forwarded as second value onwards. + */ +export function useCachedResizeValues< + TCallbackArgs extends unknown[], + TCallbackReturn, +>( + callback: (...args: TCallbackArgs) => TCallbackReturn, +): (invalidate?: boolean, ...args: TCallbackArgs) => TCallbackReturn { + const cachedValueRef = useRef(undefined); + const resizeRef = useRef(null); + + useEffect(() => { + return () => { + if (resizeRef.current) { + resizeRef.current.cleanup(); + resizeRef.current = null; + } + }; + }, []); + + return (invalidate = false, ...args: TCallbackArgs): TCallbackReturn => { + // Initialize resize instance if not already done + if (!resizeRef.current) resizeRef.current = new Resize(); + + // If we have a cached value and neither a resize nor an explicit invalidation + // has occurred since it was cached, return it as is + if ( + cachedValueRef.current !== undefined && + !resizeRef.current.resized && + !invalidate + ) { + return cachedValueRef.current; + } + + // Otherwise; recompute and cache, clearing the resize dirty flag + resizeRef.current.clear(); + const result = callback(...args); + cachedValueRef.current = result; + return result; + }; +} diff --git a/dataweaver/apps/web/src/styles/includes.scss b/dataweaver/apps/web/src/styles/includes.scss index ca30e14b..1fd4eab4 100644 --- a/dataweaver/apps/web/src/styles/includes.scss +++ b/dataweaver/apps/web/src/styles/includes.scss @@ -1,4 +1,5 @@ @forward "./includes/breakpoints.module"; @forward "./includes/helpers.module"; +@forward "./includes/typography.module"; @forward "./includes/z-indices.module"; @forward "@package/tokens/scss"; diff --git a/dataweaver/apps/web/src/styles/includes/_typography.module.scss b/dataweaver/apps/web/src/styles/includes/_typography.module.scss new file mode 100644 index 00000000..ab9430c9 --- /dev/null +++ b/dataweaver/apps/web/src/styles/includes/_typography.module.scss @@ -0,0 +1,29 @@ +@mixin type-title { + font-family: "Google Sans", sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 22px; +} + +@mixin type-body { + font-family: "Google Sans", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +@mixin type-label-large { + font-family: "Google Sans", sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 1; + text-box: trim-both cap alphabetic; +} + +@mixin type-label-small { + font-family: "Google Sans", sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1; + text-box: trim-both cap alphabetic; +} diff --git a/dataweaver/apps/web/src/utilities/resize.ts b/dataweaver/apps/web/src/utilities/resize.ts new file mode 100644 index 00000000..eb04c472 --- /dev/null +++ b/dataweaver/apps/web/src/utilities/resize.ts @@ -0,0 +1,48 @@ +type Callback = () => void; + +let isListening = false; +const CALLBACKS = new Set(); + +const emitCallbacks = () => { + for (const callback of CALLBACKS) callback(); +}; + +export class Resize { + #resized = false; + + constructor() { + CALLBACKS.add(this.#onResize); + this.#attachListenerIfFirst(); + } + + get resized(): boolean { + return this.#resized; + } + + readonly #onResize = (): void => { + this.#resized = true; + }; + + readonly clear = (): void => { + this.#resized = false; + }; + + readonly cleanup = (): void => { + CALLBACKS.delete(this.#onResize); + this.#detachListenerIfLast(); + }; + + readonly #attachListenerIfFirst = (): void => { + if (!isListening) { + window.addEventListener('resize', emitCallbacks); + isListening = true; + } + }; + + readonly #detachListenerIfLast = (): void => { + if (CALLBACKS.size === 0 && isListening) { + window.removeEventListener('resize', emitCallbacks); + isListening = false; + } + }; +} diff --git a/dataweaver/apps/web/types.d.ts b/dataweaver/apps/web/types.d.ts new file mode 100755 index 00000000..1ade946d --- /dev/null +++ b/dataweaver/apps/web/types.d.ts @@ -0,0 +1,28 @@ +import 'react'; + +declare global { + /** + * Helper to make given props required within a union type. + * + * Use this when you have a union type and want to ensure that certain + * properties are required for each member of the union, without affecting the + * overall structure of the types. + * + * @example + * ```ts + * type A = { a: number; common?: string }; + * type B = { b: string; common?: string }; + * type Union = A | B; + * + * type Result = WithRequired; + * // Result is { a: number; common: string } | { b: string; common: string } + * ``` + */ + type WithRequired = Omit & Required>; +} + +declare module 'react' { + interface CSSProperties { + [key: `--${string}`]: string | number | undefined; + } +} diff --git a/dataweaver/biome.json b/dataweaver/biome.json index 8689eaf8..d221bcbc 100644 --- a/dataweaver/biome.json +++ b/dataweaver/biome.json @@ -62,10 +62,6 @@ "useTemplate": "error", "useThrowNewError": "error", "useThrowOnlyError": "error", - "useNamingConvention": { - "level": "error", - "options": { "strictCase": false } - }, "useConsistentArrayType": { "level": "error", "options": { "syntax": "shorthand" } diff --git a/dataweaver/packages/tokens/dist/colors.css b/dataweaver/packages/tokens/dist/colors.css index 4253bc2d..2e71d733 100644 --- a/dataweaver/packages/tokens/dist/colors.css +++ b/dataweaver/packages/tokens/dist/colors.css @@ -2,9 +2,22 @@ :root { --color-surface-base: 247 252 255; - --color-surface-raised: 255 255 255; --color-surface-decorator: 227 231 237; - --color-text-primary: 32 33 36; - --color-text-secondary: 68 71 70; - --color-text-strong: 0 0 0; + --color-surface-content: 0 0 0; + --color-control-surface: 247 252 255; + --color-control-surface-hover: 227 231 237; + --color-control-accent: 11 87 208; + --color-control-accent-content: 255 255 255; + --color-control-content: 32 33 36; + --color-button-base: 194 231 255; + --color-button-base-hover: 173 214 240; + --color-button-content: 0 0 0; + --color-button-content-hover: 0 0 0; + --color-card-base: 255 255 255; + --color-card-base-selected: 11 87 208; + --color-card-content: 32 33 36; + --color-card-content-muted: 68 71 70; + --color-card-content-subtle: 196 199 197; + --color-card-skeleton: 242 242 242; + --color-shadow: 0 0 0; } diff --git a/dataweaver/packages/tokens/dist/tokens.ts b/dataweaver/packages/tokens/dist/tokens.ts index d7f66b63..abfef238 100644 --- a/dataweaver/packages/tokens/dist/tokens.ts +++ b/dataweaver/packages/tokens/dist/tokens.ts @@ -6,11 +6,24 @@ export const BREAKPOINT_DESKTOP = 1600; export const COLORS = { 'surface-base': '247, 252, 255', - 'surface-raised': '255, 255, 255', 'surface-decorator': '227, 231, 237', - 'text-primary': '32, 33, 36', - 'text-secondary': '68, 71, 70', - 'text-strong': '0, 0, 0', + 'surface-content': '0, 0, 0', + 'control-surface': '247, 252, 255', + 'control-surface-hover': '227, 231, 237', + 'control-accent': '11, 87, 208', + 'control-accent-content': '255, 255, 255', + 'control-content': '32, 33, 36', + 'button-base': '194, 231, 255', + 'button-base-hover': '173, 214, 240', + 'button-content': '0, 0, 0', + 'button-content-hover': '0, 0, 0', + 'card-base': '255, 255, 255', + 'card-base-selected': '11, 87, 208', + 'card-content': '32, 33, 36', + 'card-content-muted': '68, 71, 70', + 'card-content-subtle': '196, 199, 197', + 'card-skeleton': '242, 242, 242', + shadow: '0, 0, 0', } as const; export type Easing = [number, number, number, number]; diff --git a/dataweaver/packages/tokens/src/colors.json b/dataweaver/packages/tokens/src/colors.json index 973d6b57..47af3e6e 100644 --- a/dataweaver/packages/tokens/src/colors.json +++ b/dataweaver/packages/tokens/src/colors.json @@ -1,8 +1,21 @@ { "surface-base": [247, 252, 255], - "surface-raised": [255, 255, 255], "surface-decorator": [227, 231, 237], - "text-primary": [32, 33, 36], - "text-secondary": [68, 71, 70], - "text-strong": [0, 0, 0] + "surface-content": [0, 0, 0], + "control-surface": [247, 252, 255], + "control-surface-hover": [227, 231, 237], + "control-accent": [11, 87, 208], + "control-accent-content": [255, 255, 255], + "control-content": [32, 33, 36], + "button-base": [194, 231, 255], + "button-base-hover": [173, 214, 240], + "button-content": [0, 0, 0], + "button-content-hover": [0, 0, 0], + "card-base": [255, 255, 255], + "card-base-selected": [11, 87, 208], + "card-content": [32, 33, 36], + "card-content-muted": [68, 71, 70], + "card-content-subtle": [196, 199, 197], + "card-skeleton": [242, 242, 242], + "shadow": [0, 0, 0] } diff --git a/dataweaver/pnpm-lock.yaml b/dataweaver/pnpm-lock.yaml index 90de69d0..6ce5326b 100644 --- a/dataweaver/pnpm-lock.yaml +++ b/dataweaver/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1) tldraw: specifier: ^5.0.1 version: 5.0.1(@floating-ui/dom@1.7.6)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1361,10 +1364,27 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1557,6 +1577,33 @@ packages: '@tldraw/validate@5.0.1': resolution: {integrity: sha512-iahdvUjHa3ONn6/xHdsb5yhIpcA26Ge6zuBQqRhOJGikU353mW4B6jmQTU8B/bEsEiH1SMs/Gm+IJxqGPDP29Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -1668,6 +1715,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1677,6 +1768,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1694,6 +1788,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -1702,6 +1799,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1814,6 +1914,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} @@ -1827,6 +1933,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2120,6 +2230,21 @@ packages: peerDependencies: react: ^19.2.6 + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2158,10 +2283,29 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2326,6 +2470,9 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tldraw@5.0.1: resolution: {integrity: sha512-WPmjov5uKU27ybP0abttWYtDBXR2BHBEYRTqSeMOnJs78OHD+JmLlVCOtl826pqsrMrwZzahMcGwRY9Qs3jzHg==} peerDependencies: @@ -2384,6 +2531,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -3538,8 +3688,24 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1) + '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3791,6 +3957,30 @@ snapshots: dependencies: '@tldraw/utils': 5.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -3888,10 +4078,50 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + detect-libc@2.1.2: optional: true @@ -3905,6 +4135,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-toolkit@1.47.0: {} + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -3936,6 +4168,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -4033,6 +4267,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.8: {} + immutable@5.1.5: {} import-fresh@3.3.1: @@ -4044,6 +4282,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + is-arrayish@0.2.1: {} is-extglob@2.1.1: {} @@ -4372,6 +4612,17 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 + react-is@19.2.6: {} + + react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6): dependencies: react: 19.2.6 @@ -4403,8 +4654,36 @@ snapshots: readdirp@5.0.0: {} + recharts@3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.6) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} reusify@1.1.0: {} @@ -4622,6 +4901,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tiny-invariant@1.3.3: {} + tldraw@5.0.1(@floating-ui/dom@1.7.6)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@tiptap/core': 3.23.6(@tiptap/pm@3.23.6) @@ -4684,6 +4965,23 @@ snapshots: util-deprecate@1.0.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + w3c-keyname@2.2.8: {} which@1.3.1: