improve timeseries view
7 changed files+393−61
.vscode/tasks.jsonmodified+14−0View file
@@ -14,6 +14,20 @@
1414 "panel": "shared"
1515 },
1616 "problemMatcher": []
17+ },
18+ {
19+ "label": "Web UI Dev Server",
20+ "type": "shell",
21+ "command": "cd web-ui && npm run dev",
22+ "group": {
23+ "kind": "build",
24+ "isDefault": true
25+ },
26+ "presentation": {
27+ "reveal": "always",
28+ "panel": "new"
29+ },
30+ "problemMatcher": []
1731 }
1832 ]
1933 }
web-ui/src/components/dataset/TimeseriesView.tsxmodified+91−37View file
@@ -1,8 +1,9 @@
1-import { useEffect, useState, useMemo, useRef, useReducer } from "react";
2-import { useTimeseriesData } from "../../hooks/useTimeseriesData";
1+import { useEffect, useMemo, useReducer, useState } from "react";
2+import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
3+import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
34 import { Dataset } from "../../types";
45 import { Margins, Range, WorkerMessage } from "./WorkerTypes";
5-import { timeseriesViewReducer, initialState } from "./timeseriesViewReducer";
6+import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
67
78 interface TimeseriesViewProps {
89 width: number;
@@ -15,7 +16,11 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
1516 height,
1617 dataset,
1718 }) => {
18- const { data, error } = useTimeseriesData(dataset);
19+ const { client, error: clientError } = useTimeseriesDataClient(dataset);
20+ const [dataT, setDataT] = useState<number[] | null>(null);
21+ const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
22+ const [error, setError] = useState<string | null>(clientError);
23+ const [isLoading, setIsLoading] = useState(false);
1924
2025 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
2126 null,
@@ -25,7 +30,7 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
2530 const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
2631 const { selectedIndex, isDragging, lastDragX, xRange } = state;
2732
28- const containerRef = useRef<HTMLDivElement>(null);
33+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
2934 const [worker, setWorker] = useState<Worker | null>(null);
3035 const [margins] = useState<Margins>({
3136 left: 50,
@@ -34,23 +39,51 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
3439 bottom: 50,
3540 });
3641
37- // Update xRange when data changes
42+ // Load data for current range
3843 useEffect(() => {
39- if (data) {
44+ if (!client || !xRange) return;
45+
46+ const loadRangeData = async () => {
47+ try {
48+ setIsLoading(true);
49+ const start = Math.floor(xRange.min);
50+ const end = Math.ceil(xRange.max) + 1;
51+ const rangeData = await client.fetchRange(start, end);
52+ setDataY(rangeData);
53+ const dT = Array.from(
54+ { length: rangeData.length },
55+ (_, i) => i + start,
56+ );
57+ setDataT(dT);
58+ setError(null);
59+ } catch (err) {
60+ setError(
61+ err instanceof Error ? err.message : "Failed to load data range",
62+ );
63+ } finally {
64+ setIsLoading(false);
65+ }
66+ };
67+
68+ loadRangeData();
69+ }, [client, xRange]);
70+
71+ // Update xRange when client is initialized
72+ useEffect(() => {
73+ if (client) {
74+ const shape = client.getShape();
4075 dispatch({
4176 type: "SET_X_RANGE",
42- range: { min: 0, max: data.length - 1 },
77+ range: { min: 0, max: Math.min(999, shape - 1) },
4378 });
4479 }
45- }, [data]);
80+ }, [client]);
4681
4782 // Set up wheel event listener
4883 useEffect(() => {
49- const container = containerRef.current;
50- if (!container) return;
84+ if (!container || !client) return;
5185
5286 const handleWheel = (e: WheelEvent) => {
53- if (!data) return;
5487 e.preventDefault();
5588
5689 const rect = container.getBoundingClientRect();
@@ -62,7 +95,8 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
6295 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
6396
6497 // Calculate new range
65- const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
98+ const zoomFactor = e.deltaY > 0 ? 1.02 : 1 / 1.02;
99+ const shape = client.getShape();
66100
67101 // Ensure we don't zoom out beyond data bounds
68102 const newMin = Math.max(
@@ -70,7 +104,7 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
70104 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
71105 );
72106 const newMax = Math.min(
73- data.length - 1,
107+ shape - 1,
74108 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
75109 );
76110
@@ -81,12 +115,11 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
81115 return () => {
82116 container.removeEventListener("wheel", handleWheel);
83117 };
84- }, [data, width, margins, xRange]);
118+ }, [container, client, width, margins, xRange]);
85119
86120 // Set up mouse event listeners for panning
87121 useEffect(() => {
88- const container = containerRef.current;
89- if (!container) return;
122+ if (!container || !client) return;
90123
91124 const handleMouseDown = (e: MouseEvent) => {
92125 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
@@ -94,20 +127,21 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
94127 };
95128
96129 const handleMouseMove = (e: MouseEvent) => {
97- if (!isDragging || lastDragX === 0 || !data) return;
130+ if (!isDragging || lastDragX === 0) return;
98131
99132 const deltaX = e.clientX - lastDragX;
100133 const xRatio = deltaX / (width - margins.left - margins.right);
101134 const dataDelta = (xRange.max - xRange.min) * xRatio;
135+ const shape = client.getShape();
102136
103137 if (xRange.min - dataDelta < 0) return;
104- if (xRange.max - dataDelta > data.length - 1) return;
138+ if (xRange.max - dataDelta > shape - 1) return;
105139
106140 const newMin = xRange.min - dataDelta;
107141 const newMax = xRange.max - dataDelta;
108142
109143 // Only update if we're still within bounds
110- if (newMin >= 0 && newMax <= data.length - 1) {
144+ if (newMin >= 0 && newMax <= shape - 1) {
111145 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
112146 }
113147
@@ -128,7 +162,7 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
128162 window.removeEventListener("mousemove", handleMouseMove);
129163 window.removeEventListener("mouseup", handleMouseUp);
130164 };
131- }, [data, width, margins, xRange, isDragging, lastDragX]);
165+ }, [container, client, width, margins, xRange, isDragging, lastDragX]);
132166
133167 // Set worker
134168 useEffect(() => {
@@ -164,33 +198,37 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
164198
165199 // Calculate yRange from data
166200 const yRange = useMemo<Range>(() => {
167- if (!data) return { min: 0, max: 1 };
201+ if (!dataY) return { min: 0, max: 1 };
202+ const values = Array.from(dataY);
168203 return {
169- min: Math.min(...data),
170- max: Math.max(...data),
204+ min: Math.min(...values),
205+ max: Math.max(...values),
171206 };
172- }, [data]);
207+ }, [dataY]);
173208
174209 // Handle dimension changes
175210 useEffect(() => {
176211 if (!worker) return;
177- if (!data) return;
212+ if (!dataY) return;
213+ if (!dataT) return;
178214
179215 const msg: WorkerMessage = {
180216 type: "render",
181- timeseries: data,
217+ timeseriesT: dataT,
218+ timeseriesY: Array.from(dataY),
182219 width,
183220 height,
184221 margins,
185222 xRange,
186223 yRange,
187224 };
225+ console.log("--- posting message to worker", msg);
188226 worker.postMessage(msg);
189- }, [width, height, data, worker, margins, xRange, yRange]);
227+ }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
190228
191229 // Render cursor on overlay canvas
192230 useEffect(() => {
193- if (!overlayCanvasElement || selectedIndex === null || !data) return;
231+ if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
194232 const ctx = overlayCanvasElement.getContext("2d");
195233 if (!ctx) return;
196234
@@ -213,21 +251,36 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
213251 width,
214252 height,
215253 margins,
216- data,
254+ dataT,
255+ dataY,
217256 xRange,
218257 ]);
219258
220- if (error) {
221- return <div>Error loading data: {error}</div>;
259+ const selectedValue = useMemo(() => {
260+ if (selectedIndex === -1 || !dataT || !dataY) return null;
261+ for (let i = 0; i < dataT.length; i++) {
262+ if (dataT[i] === selectedIndex) {
263+ return dataY[i];
264+ }
265+ }
266+ return null;
267+ }, [selectedIndex, dataT, dataY]);
268+
269+ if (error || clientError) {
270+ return <div>Error loading data: {error || clientError}</div>;
271+ }
272+
273+ if (isLoading && !dataY) {
274+ return <div>Loading...</div>;
222275 }
223276
224277 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
225- if (!overlayCanvasElement || !data || isDragging) return;
278+ if (!overlayCanvasElement || !dataY || isDragging) return;
226279 const rect = overlayCanvasElement.getBoundingClientRect();
227280 const x = e.clientX - rect.left;
228281 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
229282 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
230- if (index >= 0 && index < data.length) {
283+ if (index >= 0) {
231284 dispatch({ type: "SET_SELECTED_INDEX", index });
232285 }
233286 };
@@ -235,12 +288,13 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
235288 return (
236289 <div style={{ position: "relative", width, height: height + 30 }}>
237290 <div
238- ref={containerRef}
291+ ref={setContainer}
239292 style={{ position: "relative", width, height }}
240293 onClick={handleCanvasClick}
241294 >
242295 <canvas
243296 ref={setCanvasElement}
297+ key={`canvas-${width}-${height}`}
244298 width={width}
245299 height={height}
246300 style={{
@@ -261,9 +315,9 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
261315 }}
262316 />
263317 </div>
264- {selectedIndex !== -1 && data && (
318+ {selectedIndex !== -1 && dataY && (
265319 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
266- Index: {selectedIndex}, Value: {data[selectedIndex].toFixed(3)}
320+ Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
267321 </div>
268322 )}
269323 </div>
web-ui/src/components/dataset/TimeseriesViewWorker.tsmodified+66−12View file
@@ -19,16 +19,33 @@ function getNiceTickInterval(range: number, maxTicks: number): number {
1919 return Math.ceil(niceIntervals[niceIntervals.length - 1] * magnitude * 10);
2020 }
2121
22+// Helper function to estimate the width of a number in pixels
23+// This is an approximation since we can't measure text width directly in a worker
24+function estimateNumberWidth(num: number): number {
25+ const numStr = Math.abs(num).toString();
26+ const digitWidth = 8; // Approximate width of a digit in pixels
27+ const padding = 4; // Padding between numbers
28+ return (numStr.length + (num < 0 ? 1 : 0)) * digitWidth + padding;
29+}
30+
2231 // Helper function to get tick positions
2332 function getTickPositions(
2433 range: Range,
2534 width: number,
35+ considerNumberWidth = false, // Only true for x-axis where we need to handle large integers
2636 ): { value: number; x: number }[] {
27- const pixelsPerTick = 20; // Minimum pixels between ticks
37+ let pixelsPerTick = 20; // Default minimum pixels between ticks
38+
39+ if (considerNumberWidth) {
40+ // For x-axis, calculate spacing based on largest number width
41+ const maxAbsValue = Math.max(Math.abs(range.min), Math.abs(range.max));
42+ const maxNumberWidth = estimateNumberWidth(maxAbsValue);
43+ pixelsPerTick = Math.max(maxNumberWidth, 20); // Use the larger of estimated width or minimum spacing
44+ }
2845 const maxTicks = Math.floor(width / pixelsPerTick);
2946 const tickInterval = getNiceTickInterval(range.max - range.min, maxTicks);
3047
31- const firstTick = Math.ceil(range.min);
48+ const firstTick = Math.ceil(range.min / tickInterval) * tickInterval;
3249 const lastTick = Math.floor(range.max);
3350
3451 const ticks: { value: number; x: number }[] = [];
@@ -46,7 +63,8 @@ let canvas: OffscreenCanvas | null = null;
4663 let ctx: OffscreenCanvasRenderingContext2D | null = null;
4764
4865 function renderTimeseries(
49- timeseries: number[],
66+ timeseriesT: number[],
67+ timeseriesY: number[],
5068 width: number,
5169 height: number,
5270 margins: Margins,
@@ -96,11 +114,10 @@ function renderTimeseries(
96114
97115 // Draw the path
98116 let isFirst = true;
99- for (let i = Math.floor(xRange.min); i <= Math.ceil(xRange.max); i++) {
100- if (i < 0 || i >= timeseries.length) continue;
101- const value = timeseries[i];
102- const x = margins.left + (i - xRange.min) * xScale;
103- const y = margins.top + drawingHeight - (value - yRange.min) * yScale;
117+ for (let i = 0; i < timeseriesT.length; i++) {
118+ const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
119+ const y =
120+ margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
104121 if (isFirst) {
105122 context.moveTo(x, y);
106123 isFirst = false;
@@ -136,7 +153,7 @@ function renderTimeseries(
136153 });
137154
138155 // Draw X-axis ticks and labels
139- const ticks = getTickPositions(xRange, drawingWidth);
156+ const ticks = getTickPositions(xRange, drawingWidth, true); // Consider number width for x-axis
140157
141158 context.textAlign = "center";
142159 context.textBaseline = "top";
@@ -175,11 +192,48 @@ self.onmessage = (evt: MessageEvent) => {
175192 }
176193
177194 if (message.type === "render") {
178- const { timeseries, width, height, margins, xRange, yRange } = message;
179- renderTimeseries(timeseries, width, height, margins, xRange, yRange);
180- self.postMessage({ type: "render_complete" });
195+ throttleRender(() => {
196+ const {
197+ timeseriesT,
198+ timeseriesY,
199+ width,
200+ height,
201+ margins,
202+ xRange,
203+ yRange,
204+ } = message;
205+ renderTimeseries(
206+ timeseriesT,
207+ timeseriesY,
208+ width,
209+ height,
210+ margins,
211+ xRange,
212+ yRange,
213+ );
214+ self.postMessage({ type: "render_complete" });
215+ });
181216 return;
182217 }
183218 };
184219
220+let renderStack: (() => void)[] = [];
221+let lastRenderTime = 0;
222+
223+const throttleRender = (callback: () => void) => {
224+ renderStack.push(callback);
225+ const checkRender = () => {
226+ if (renderStack.length === 0) return;
227+ const elapsed = Date.now() - lastRenderTime;
228+ if (elapsed > 100) {
229+ lastRenderTime = Date.now();
230+ renderStack[renderStack.length - 1]();
231+ renderStack = [];
232+ } else {
233+ setTimeout(checkRender, 150);
234+ }
235+ };
236+ checkRender();
237+};
238+
185239 export {}; // Needed for TypeScript modules
web-ui/src/components/dataset/WorkerTypes.tsmodified+2−1View file
@@ -14,7 +14,8 @@ export type WorkerMessage =
1414 | { type: "initialize"; canvas: OffscreenCanvas }
1515 | {
1616 type: "render";
17- timeseries: number[];
17+ timeseriesT: number[];
18+ timeseriesY: number[];
1819 width: number;
1920 height: number;
2021 margins: Margins;
web-ui/src/hooks/TimeseriesDataClient.tsadded+153−0View file
@@ -0,0 +1,153 @@
1+export type SupportedTypedArray =
2+ | Uint8Array
3+ | Uint16Array
4+ | Uint32Array
5+ | Int16Array
6+ | Int32Array;
7+
8+interface ChunkCache {
9+ [key: number]: SupportedTypedArray;
10+}
11+
12+type DType = "uint8" | "uint16" | "uint32" | "int16" | "int32";
13+
14+const TypedArrayConstructors = {
15+ uint8: Uint8Array,
16+ uint16: Uint16Array,
17+ uint32: Uint32Array,
18+ int16: Int16Array,
19+ int32: Int32Array,
20+} as const;
21+
22+export class TimeseriesDataClient {
23+ private shape: number = 0;
24+ private dtype: DType | null = null;
25+ private chunkSize: number;
26+ private cache: ChunkCache = {};
27+ private datasetJsonUrl: string;
28+ private datasetDataUrl: string;
29+
30+ constructor(
31+ datasetJsonUrl: string,
32+ datasetDataUrl: string,
33+ chunkSize: number = 100000,
34+ ) {
35+ this.datasetJsonUrl = datasetJsonUrl;
36+ this.datasetDataUrl = datasetDataUrl;
37+ this.chunkSize = chunkSize;
38+ }
39+
40+ static async create(
41+ datasetJsonUrl: string,
42+ datasetDataUrl: string,
43+ chunkSize: number = 1000,
44+ ): Promise<TimeseriesDataClient> {
45+ const client = new TimeseriesDataClient(
46+ datasetJsonUrl,
47+ datasetDataUrl,
48+ chunkSize,
49+ );
50+ await client.initialize();
51+ return client;
52+ }
53+
54+ private async initialize() {
55+ const infoUrl = this.datasetJsonUrl;
56+ const response = await fetch(infoUrl);
57+ if (!response.ok) {
58+ throw new Error(`Failed to fetch dataset info: ${response.statusText}`);
59+ }
60+ const info = await response.json();
61+ this.shape = info.shape[0];
62+
63+ if (!this.isValidDType(info.dtype)) {
64+ throw new Error(`Unsupported data type: ${info.dtype}`);
65+ }
66+ this.dtype = info.dtype;
67+ }
68+
69+ private isValidDType(dtype: string): dtype is DType {
70+ return dtype in TypedArrayConstructors;
71+ }
72+
73+ private getChunkIndices(start: number, end: number): number[] {
74+ const startChunk = Math.floor(start / this.chunkSize);
75+ const endChunk = Math.floor(end / this.chunkSize);
76+ const chunks: number[] = [];
77+ for (let i = startChunk; i <= endChunk; i++) {
78+ chunks.push(i);
79+ }
80+ return chunks;
81+ }
82+
83+ private async fetchChunk(chunkIndex: number): Promise<SupportedTypedArray> {
84+ if (this.cache[chunkIndex]) {
85+ return this.cache[chunkIndex];
86+ }
87+
88+ if (!this.dtype) {
89+ throw new Error("Data type not initialized");
90+ }
91+
92+ const start = chunkIndex * this.chunkSize;
93+ const end = Math.min(start + this.chunkSize, this.shape);
94+ const url = this.datasetDataUrl;
95+ const itemSize = TypedArrayConstructors[this.dtype].BYTES_PER_ELEMENT;
96+ const byteStart = start * itemSize;
97+ const byteEnd = end * itemSize;
98+
99+ const response = await fetch(url, {
100+ headers: {
101+ Range: `bytes=${byteStart}-${byteEnd - 1}`,
102+ },
103+ });
104+ if (!response.ok) {
105+ throw new Error(`Failed to fetch chunk: ${response.statusText}`);
106+ }
107+
108+ const buffer = await response.arrayBuffer();
109+ const ArrayConstructor = TypedArrayConstructors[this.dtype];
110+ const data = new ArrayConstructor(buffer);
111+ this.cache[chunkIndex] = data;
112+
113+ return data;
114+ }
115+
116+ async fetchRange(start: number, end: number): Promise<SupportedTypedArray> {
117+ if (!this.dtype) {
118+ throw new Error("Data type not initialized");
119+ }
120+
121+ const chunkIndices = this.getChunkIndices(start, end);
122+ const chunks = await Promise.all(
123+ chunkIndices.map((idx) => this.fetchChunk(idx)),
124+ );
125+
126+ // Calculate total length needed
127+ const length = end - start;
128+ const ArrayConstructor = TypedArrayConstructors[this.dtype];
129+ const result = new ArrayConstructor(length);
130+
131+ // Copy data from chunks into result array
132+ let resultOffset = 0;
133+ for (let i = 0; i < chunks.length; i++) {
134+ const chunk = chunks[i];
135+ const chunkStart = chunkIndices[i] * this.chunkSize;
136+ const copyStart = Math.max(0, start - chunkStart);
137+ const copyEnd = Math.min(chunk.length, end - chunkStart);
138+ const copyLength = copyEnd - copyStart;
139+ result.set(chunk.subarray(copyStart, copyEnd), resultOffset);
140+ resultOffset += copyLength;
141+ }
142+
143+ return result;
144+ }
145+
146+ getShape(): number {
147+ return this.shape;
148+ }
149+
150+ getDType(): DType | null {
151+ return this.dtype;
152+ }
153+}
web-ui/src/hooks/useTimeseriesDataClient.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+import { useEffect, useState } from "react";
2+import { Dataset } from "../types";
3+import { TimeseriesDataClient } from "./TimeseriesDataClient";
4+
5+interface UseTimeseriesDataClientResult {
6+ client: TimeseriesDataClient | null;
7+ error: string | null;
8+}
9+
10+export const useTimeseriesDataClient = (
11+ dataset: Dataset,
12+ chunkSize: number = 1000,
13+): UseTimeseriesDataClientResult => {
14+ const [client, setClient] = useState<TimeseriesDataClient | null>(null);
15+ const [error, setError] = useState<string | null>(null);
16+
17+ useEffect(() => {
18+ const initClient = async () => {
19+ try {
20+ const newClient = await TimeseriesDataClient.create(
21+ dataset.data_url_json || "",
22+ dataset.data_url_raw || "",
23+ chunkSize,
24+ );
25+ setClient(newClient);
26+ setError(null);
27+ } catch (err) {
28+ setError(
29+ err instanceof Error ? err.message : "Failed to initialize client",
30+ );
31+ setClient(null);
32+ }
33+ };
34+
35+ initClient();
36+ }, [dataset.data_url_json, dataset.data_url_raw, chunkSize]);
37+
38+ return { client, error };
39+};
web-ui/src/pages/Dataset.tsxmodified+28−11View file
@@ -1,12 +1,33 @@
11 import { useParams } from "react-router-dom";
22 import { Dataset as DatasetType } from "../types";
33 import TimeseriesView from "../components/dataset/TimeseriesView";
4+import { useEffect, useRef, useState } from "react";
45
56 interface DatasetProps {
67 datasets: DatasetType[];
78 }
89
910 function Dataset({ datasets }: DatasetProps) {
11+ const containerRef = useRef<HTMLDivElement>(null);
12+ const [containerWidth, setContainerWidth] = useState(1200);
13+
14+ useEffect(() => {
15+ if (!containerRef.current) return;
16+
17+ const resizeObserver = new ResizeObserver((entries) => {
18+ for (const entry of entries) {
19+ // Account for padding by subtracting 32px (2rem)
20+ setContainerWidth(entry.contentRect.width - 32);
21+ }
22+ });
23+
24+ resizeObserver.observe(containerRef.current);
25+
26+ return () => {
27+ resizeObserver.disconnect();
28+ };
29+ }, []);
30+
1031 const { datasetName } = useParams<{ datasetName: string }>();
1132 const dataset = datasets.find((d) => d.name === datasetName);
1233
@@ -26,23 +47,15 @@ function Dataset({ datasets }: DatasetProps) {
2647 >
2748 {dataset.name}
2849 </h1>
29- <div style={{ maxWidth: "800px", margin: "0 auto" }}>
50+ <div>
3051 <div style={{ marginBottom: "1.5rem" }}>
31- <h2
32- style={{
33- fontSize: "1.2rem",
34- fontWeight: "bold",
35- marginBottom: "0.5rem",
36- }}
37- >
38- Description
39- </h2>
4052 <p style={{ fontSize: "0.9rem", lineHeight: "1.5" }}>
4153 {dataset.description}
4254 </p>
4355 </div>
4456 <div style={{ marginBottom: "1.5rem" }}>
4557 <div
58+ ref={containerRef}
4659 style={{
4760 width: "100%",
4861 height: "300px",
@@ -51,7 +64,11 @@ function Dataset({ datasets }: DatasetProps) {
5164 padding: "1rem",
5265 }}
5366 >
54- <TimeseriesView width={700} height={250} dataset={dataset} />
67+ <TimeseriesView
68+ width={containerWidth}
69+ height={250}
70+ dataset={dataset}
71+ />
5572 </div>
5673 </div>
5774 <div style={{ marginBottom: "1.5rem" }}>