diff --git a/dataweaver/apps/web/src/components/elements/card/base.tsx b/dataweaver/apps/web/src/components/elements/card/base.tsx index 8c085504..cab85370 100644 --- a/dataweaver/apps/web/src/components/elements/card/base.tsx +++ b/dataweaver/apps/web/src/components/elements/card/base.tsx @@ -10,7 +10,6 @@ import type { TLShapeId } from 'tldraw'; import { Button } from '~/components/elements/button'; import { CARD_VARIANT_MAX } from '~/components/scopes/atlas/config'; import type { CardVariant } from '~/components/scopes/atlas/helpers'; -import { useCachedResizeValues } from '~/hooks/use_cached_resize_values'; import s from './base.module.scss'; import { useCardAutoHeight } from './use_card_auto_height'; import { useCardClearTextSelection } from './use_card_clear_text_selection'; @@ -68,10 +67,6 @@ export const CardBase = ({ const startDragging = useCardDragHandle(id); - const getCachedCanScroll = useCachedResizeValues((element: HTMLElement) => { - return element.scrollHeight > element.clientHeight; - }); - return (
{ - if (getCachedCanScroll(false, event.currentTarget)) { - event.stopPropagation(); + let el = event.target as HTMLElement | null; + while (el && el !== event.currentTarget.parentElement) { + const style = window.getComputedStyle(el); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + if (el.scrollHeight > el.clientHeight) { + event.stopPropagation(); + return; + } + } + el = el.parentElement; } }} // Once the card is the single selection, reserve dragging for the 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 index ce785ddb..ae8015b9 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.module.scss @@ -1,12 +1,14 @@ .container { display: flex; - flex-shrink: 0; + flex: 1; flex-direction: column; - padding: 28px; + min-height: 0; + padding: 28px 28px 0; } .header-container { display: flex; + flex-shrink: 0; flex-direction: column; row-gap: 8px; margin-bottom: 16px; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx index 03937b6e..f360a62c 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx +++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx @@ -19,6 +19,8 @@ import { useQueryActions } from '~/components/scopes/atlas/query_provider'; import type { FacetInfo } from '~/server/types'; import s from './chart.module.scss'; import { ConditionalTabs } from './conditional_tabs'; +import { DataChartBarHorizontal } from './data_chart_bar_horizontal'; +import { DataChartBarVertical } from './data_chart_bar_vertical'; import { DataChartLine } from './data_chart_line'; import { DataTable } from './data_table'; import { FacetSelector } from './facet_selector'; @@ -29,10 +31,6 @@ export interface ChartDatum { value: number; } -// TODO: Get dynamically instead of hard coding here -const CHART_WIDTH = 356; -const CHART_HEIGHT = 200; - export interface CardChartProps extends CardState { id: TLShapeId; title?: string; @@ -60,7 +58,6 @@ export const CardChart = ({ const { open: openExport } = useExportActions(); const { runPrompt } = useQueryActions(); - // TODO: Support the different chart styles (for now we always show bar chart) const [selectedStyle, setSelectedStyle] = useState('bar-vertical'); const [isStyleMenuOpen, setIsStyleMenuOpen] = useState(false); @@ -71,7 +68,6 @@ export const CardChart = ({ // Derive chart data from selected facet if facets are available const currentFacet = facets?.find((f) => f.facetId === selectedFacetId); const chartData = currentFacet?.observations ?? data; - return ( - ), + children: + selectedStyle === 'bar-vertical' ? ( + + ) : selectedStyle === 'bar-horizontal' ? ( + + ) : ( + + ), }, { icon: IconTable, 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 index cb3ff3a7..2aab5957 100644 --- 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 @@ -1,6 +1,6 @@ .tabs-container { display: flex; - margin-bottom: 16px; + flex-shrink: 0; border-bottom: 1px solid rgb(var(--color-card-divider)); } @@ -37,3 +37,17 @@ width: 24px; height: 24px; } + +.panel { + font-weight: 500; + + &:has(table) { + overflow-y: auto; + + thead { + position: sticky; + top: 0; + z-index: $z-index-above-content; + } + } +} 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 index 44c5eb21..792ec2ba 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.tsx +++ b/dataweaver/apps/web/src/components/elements/card/chart/conditional_tabs.tsx @@ -59,6 +59,7 @@ export const ConditionalTabs = ({ tabs }: ConditionalTabsProps) => { { + if (active && payload && payload.length) { + const value = tooltipFormatter.format(Number(payload?.[0]?.value)); + return ( +
+

{`${label}`}

+

{`${value}`}

+
+ ); + } + return null; +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_horizontal.tsx b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_horizontal.tsx new file mode 100644 index 00000000..d0338bd5 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_horizontal.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { COLORS } from '@package/tokens/ts'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDatum } from './chart'; +import { CustomTooltip } from './custom_tooltip'; + +const BAR_COLOR = `rgb(${COLORS['card-surface-selected']})`; +const GRID_COLOR = `rgb(${COLORS['card-chart-grid']})`; +const AXIS_COLOR = `rgb(${COLORS['card-content-muted']})`; + +interface ChartProps { + data: ChartDatum[]; +} + +const compactFormatter = new Intl.NumberFormat(undefined, { + notation: 'compact', +}); + +export const DataChartBarHorizontal = ({ data }: ChartProps) => { + return ( + + + + compactFormatter.format(value)} + tickMargin={6} + /> + + } + /> + + + + ); +}; diff --git a/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_vertical.tsx b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_vertical.tsx new file mode 100644 index 00000000..e9cbe827 --- /dev/null +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_chart_bar_vertical.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { COLORS } from '@package/tokens/ts'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + usePlotArea, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDatum } from './chart'; +import { CustomTooltip } from './custom_tooltip'; + +const BAR_COLOR = `rgb(${COLORS['card-surface-selected']})`; +const GRID_COLOR = `rgb(${COLORS['card-chart-grid']})`; +const AXIS_COLOR = `rgb(${COLORS['card-content-muted']})`; + +interface ChartProps { + data: ChartDatum[]; +} + +const compactFormatter = new Intl.NumberFormat(undefined, { + notation: 'compact', +}); + +const CustomCursor = (props: { + x?: number; + y?: number; + width?: number; + height?: number; + top?: number; +}) => { + const { x = 0, width = 0, height = 0, top = 0 } = props; + return ( + + ); +}; + +const FullWidthAxisLine = () => { + const plotArea = usePlotArea(); + if (!plotArea) return null; + const y = plotArea.y + plotArea.height; + return ( + + ); +}; + +export const DataChartBarVertical = ({ data }: ChartProps) => { + return ( + + + + + compactFormatter.format(Number(value))} + /> + + } content={} /> + + + + ); +}; 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 index dba89046..96b186ed 100644 --- 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 @@ -1,50 +1,125 @@ 'use client'; import { COLORS } from '@package/tokens/ts'; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + usePlotArea, + XAxis, + YAxis, +} from 'recharts'; + import type { ChartDatum } from './chart'; +import { CustomTooltip } from './custom_tooltip'; const LINE_COLOR = `rgb(${COLORS['card-surface-selected']})`; const GRID_COLOR = `rgb(${COLORS['card-chart-grid']})`; const AXIS_COLOR = `rgb(${COLORS['card-content-muted']})`; +const DOT_COLOR = `rgb(${COLORS['card-surface']})`; interface ChartProps { data: ChartDatum[]; - width: number; - height: number; } -const compactFormatter = new Intl.NumberFormat('en', { notation: 'compact' }); +const compactFormatter = new Intl.NumberFormat(undefined, { + notation: 'compact', +}); + +const CustomCursor = (props: { + points?: { x: number; y: number }[]; + top?: number; + height?: number; +}) => { + const { points, top = 0 } = props; + const point = points?.[0]; + if (!point) return null; + const x = point.x; + return ( + + ); +}; + +const FullWidthAxisLine = () => { + const plotArea = usePlotArea(); + if (!plotArea) return null; + const y = plotArea.y + plotArea.height; + return ( + + ); +}; -export const DataChartLine = ({ data, width, height }: ChartProps) => { +export const DataChartLine = ({ data }: ChartProps) => { return ( - - - - compactFormatter.format(v)} - /> - - + + + + compactFormatter.format(Number(value))} + /> + + } content={} /> + 15 + ? false + : { + r: 3, + fill: DOT_COLOR, + } + } + activeDot={{ + fill: LINE_COLOR, + stroke: LINE_COLOR, + strokeWidth: 1, + }} + /> + + ); }; 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 index 2ee2f0dc..4773b572 100644 --- 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 @@ -2,10 +2,21 @@ @include type-label-small; width: 100%; + margin-bottom: 16px; text-align: left; + + tr:nth-child(odd) { + background-color: rgb(var(--color-skeleton-surface)); + } } .table th, .table td { padding: 4px 8px; } + +.table th { + padding-top: 16px; + background-color: rgb(var(--color-card-surface)); + box-shadow: inset 0 -1px 0 rgb(var(--color-card-divider)); +} 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 index 6b935937..fc12ac69 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/data_table.tsx +++ b/dataweaver/apps/web/src/components/elements/card/chart/data_table.tsx @@ -5,7 +5,9 @@ interface DataTableProps { data: ChartDatum[]; } -const compactFormatter = new Intl.NumberFormat('en', { notation: 'compact' }); +const compactFormatter = new Intl.NumberFormat(undefined, { + notation: 'standard', +}); export const DataTable = ({ data }: DataTableProps) => { return ( diff --git a/dataweaver/apps/web/src/components/elements/card/chart/facet_selector.module.scss b/dataweaver/apps/web/src/components/elements/card/chart/facet_selector.module.scss index be9eddc3..f94fcbd1 100644 --- a/dataweaver/apps/web/src/components/elements/card/chart/facet_selector.module.scss +++ b/dataweaver/apps/web/src/components/elements/card/chart/facet_selector.module.scss @@ -2,7 +2,6 @@ position: relative; display: flex; gap: 8px; - align-items: center; margin-bottom: 12px; } @@ -45,16 +44,15 @@ min-width: 280px; margin-top: 4px; overflow: hidden; - background: rgb(var(--color-surface-base)); - border: 1px solid rgb(var(--color-surface-decorator)); + background: rgb(var(--color-card-surface)); border-radius: 8px; - box-shadow: 0 2px 6px rgb(0 0 0 / 15%); + box-shadow: var(--shadow-elevated); } .option { display: flex; flex-direction: column; - gap: 2px; + gap: 8px; width: 100%; padding: 8px 12px; text-align: left; @@ -70,6 +68,10 @@ &[data-is-selected="true"] { background: rgb(var(--color-control-accent) / 12%); } + + &:hover:not([data-is-selected="true"]) { + background: rgb(var(--color-control-accent) / 6%); + } } .option-source { diff --git a/dataweaver/apps/web/src/components/elements/card/use_card_auto_height.ts b/dataweaver/apps/web/src/components/elements/card/use_card_auto_height.ts index db37140a..32c26175 100644 --- a/dataweaver/apps/web/src/components/elements/card/use_card_auto_height.ts +++ b/dataweaver/apps/web/src/components/elements/card/use_card_auto_height.ts @@ -76,8 +76,34 @@ export const useCardAutoHeight = ( }; sync(); - const observer = new ResizeObserver(sync); - observer.observe(container); - return () => observer.disconnect(); + + // --- ResizeObserver: catches size changes of the container itself --- + const resizeObserver = new ResizeObserver(sync); + resizeObserver.observe(container); + + // --- MutationObserver: catches async child content insertion --- + // Recharts' ResponsiveContainer (and similar libraries) render chart SVGs + // asynchronously after measuring their parent width. Once useCardAutoHeight + // writes a smaller `h`, the container's box is max-height-capped so the + // ResizeObserver won't fire when new children appear (the box doesn't + // grow). A MutationObserver detects these insertions and re-measures. + // Debounced via rAF to avoid layout thrashing from rapid subtree mutations + // (e.g. tooltips appearing/disappearing on hover). + let mutationFrameId: number | null = null; + const syncOnMutation = () => { + if (mutationFrameId !== null) return; + mutationFrameId = requestAnimationFrame(() => { + mutationFrameId = null; + sync(); + }); + }; + const mutationObserver = new MutationObserver(syncOnMutation); + mutationObserver.observe(container, { childList: true, subtree: true }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + if (mutationFrameId !== null) cancelAnimationFrame(mutationFrameId); + }; }, [editor, shapeId, maxHeight]); }; diff --git a/dataweaver/apps/web/src/components/scopes/atlas/config.ts b/dataweaver/apps/web/src/components/scopes/atlas/config.ts index efe4699f..6cf31e56 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/config.ts +++ b/dataweaver/apps/web/src/components/scopes/atlas/config.ts @@ -95,7 +95,7 @@ export const ZOOM_STEPS: readonly number[] = Array.from( export const CARD_VARIANT_MAX: Record = { text: { w: 420, h: 440 }, table: { w: 650, h: 500 }, - chart: { w: 420, h: 520 }, + chart: { w: 420, h: 720 }, }; /** Minimum gap to keep between a placed card and any other card, in px. */ diff --git a/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx index 1854ff27..7acb96f5 100644 --- a/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx +++ b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx @@ -79,7 +79,7 @@ export class ShapeCardUtil extends ShapeUtil { // Each card variant owns its own actions, content and footer; the shape just // hands it the state and content it needs to render itself. #renderCard = (shape: ShapeCard) => { - const { variant, title, description, body, data, relatedQueries } = + const { variant, title, description, body, data, facets, relatedQueries } = shape.props; const isLoading = shape.props.isLoading ?? false; @@ -107,6 +107,7 @@ export class ShapeCardUtil extends ShapeUtil { title={title} description={description} data={data} + facets={facets} relatedQueries={relatedQueries} /> );