@reshape-biotech/design-system 2.7.37 → 2.7.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,12 @@
10
10
  });
11
11
 
12
12
  const data: DataPoint[] = [
13
- { value: [10.0, 8.04], metadata: { blip: '1', blop: 1, user_id: '1' } },
14
- { value: [8.07, 6.95], metadata: { blip: '2', blop: 2, user_id: '2' } },
15
- { value: [13.0, 7.58], metadata: { blip: '3', blop: 3, user_id: '3' } },
16
- { value: [9.05, 8.81], metadata: { blip: '4', blop: 4, user_id: '4' } },
17
- { value: [12.0, 8.33], metadata: { blip: '5', blop: 5, user_id: '5' } },
18
- { value: [14.0, 7.66], metadata: { blip: '6', blop: 6, user_id: '6' } },
13
+ { value: [10, 8], metadata: { id: '1' } },
14
+ { value: [8, 7], metadata: { id: '2' } },
15
+ { value: [13, 8], metadata: { id: '3' } },
16
+ { value: [9, 9], metadata: { id: '4' } },
17
+ { value: [12, 8], metadata: { id: '5' } },
18
+ { value: [14, 8], metadata: { id: '6' } },
19
19
  ];
20
20
 
21
21
  const errorBarsData: DataPoint[] = data.map((d) => ({
@@ -28,78 +28,147 @@
28
28
  [12, 14],
29
29
  ];
30
30
 
31
- function handleItemClick(params: echarts.ECElementEvent) {}
32
-
33
- function handleMouseOver(params: echarts.ECElementEvent) {}
34
-
35
- function handleMouseOut() {}
31
+ let tooltipState = $state({ visible: false, x: 0, y: 0, point: null as DataPoint | null });
36
32
  </script>
37
33
 
38
34
  <Story name="Base" asChild>
39
35
  <div class="h-[400px] w-full">
40
- <Scatterplot
41
- {data}
42
- xAxisName="Manual count"
43
- yAxisName="Analysis count"
44
- onitemclick={handleItemClick}
45
- onmouseover={handleMouseOver}
46
- onmouseout={handleMouseOut}
47
- />
36
+ <Scatterplot {data} xAxisName="X axis" yAxisName="Y axis" />
48
37
  </div>
49
38
  </Story>
50
39
 
51
- <Story name="With Line Data" asChild>
40
+ <Story name="With reference line" asChild>
52
41
  <div class="h-[400px] w-full">
53
- <Scatterplot
54
- {data}
55
- {lineData}
56
- xAxisName="Manual count"
57
- yAxisName="Analysis count"
58
- onitemclick={handleItemClick}
59
- onmouseover={handleMouseOver}
60
- onmouseout={handleMouseOut}
61
- />
42
+ <Scatterplot {data} {lineData} xAxisName="X axis" yAxisName="Y axis" />
62
43
  </div>
63
44
  </Story>
64
- <Story name="Error bars" asChild>
45
+
46
+ <Story name="With error bars" asChild>
47
+ <div class="h-[400px] w-full">
48
+ <Scatterplot data={errorBarsData} {lineData} xAxisName="X axis" yAxisName="Y axis" />
49
+ </div>
50
+ </Story>
51
+
52
+ <Story name="With confidence band" asChild>
65
53
  <div class="h-[400px] w-full">
66
54
  <Scatterplot
67
55
  data={errorBarsData}
68
56
  {lineData}
69
- xAxisName="Manual count"
70
- yAxisName="Analysis count"
71
- onitemclick={handleItemClick}
72
- onmouseover={handleMouseOver}
73
- onmouseout={handleMouseOut}
57
+ xAxisName="X axis"
58
+ yAxisName="Y axis"
59
+ showConfidenceBand
74
60
  />
75
61
  </div>
76
62
  </Story>
77
- <Story name="Error bars with confidence band" asChild>
63
+
64
+ <Story name="With legend" asChild>
78
65
  <div class="h-[400px] w-full">
79
66
  <Scatterplot
80
- data={errorBarsData}
67
+ {data}
81
68
  {lineData}
82
- xAxisName="Manual count"
83
- yAxisName="Analysis count"
69
+ xAxisName="X axis"
70
+ yAxisName="Y axis"
84
71
  showConfidenceBand
85
- onitemclick={handleItemClick}
86
- onmouseover={handleMouseOver}
87
- onmouseout={handleMouseOut}
72
+ showLegend
88
73
  />
89
74
  </div>
90
75
  </Story>
91
- <Story name="Confidence band and legend" asChild>
76
+
77
+ <Story name="With groups" asChild>
78
+ {@const data: DataPoint[] = [
79
+ { value: [8, 7], metadata: { id: 'A' } },
80
+ { value: [8, 10], metadata: { id: 'A' } },
81
+ { value: [15, 14], metadata: { id: 'B' } },
82
+ { value: [23, 21], metadata: { id: 'C' } },
83
+ { value: [23, 25], metadata: { id: 'C' } },
84
+ { value: [23, 22], metadata: { id: 'C' } },
85
+ { value: [35, 32], metadata: { id: 'D' } },
86
+ { value: [35, 39], metadata: { id: 'D' }, disagreement: true },
87
+ { value: [42, 41], metadata: { id: 'E' } },
88
+ { value: [42, 44], metadata: { id: 'E' } },
89
+ { value: [56, 58], metadata: { id: 'F' } },
90
+ ]}
91
+ {@const groups = [[0, 1], [2], [3, 4, 5], [6, 7], [8, 9], [10]]}
92
+ {@const lineData: [[number, number], [number, number]] = [[0, 0], [65, 65]]}
92
93
  <div class="h-[400px] w-full">
93
94
  <Scatterplot
94
95
  {data}
96
+ {groups}
95
97
  {lineData}
96
- xAxisName="Manual count"
97
- yAxisName="Analysis count"
98
+ xAxisName="X axis"
99
+ yAxisName="Y axis"
98
100
  showConfidenceBand
99
- showLegend
100
- onitemclick={handleItemClick}
101
- onmouseover={handleMouseOver}
102
- onmouseout={handleMouseOut}
103
- />
101
+ onmouseover={(params) => {
102
+ tooltipState.visible = true;
103
+ tooltipState.x = (params.event?.event as MouseEvent)?.offsetX ?? 0;
104
+ tooltipState.y = (params.event?.event as MouseEvent)?.offsetY ?? 0;
105
+ tooltipState.point = params.data;
106
+ }}
107
+ onmouseout={() => {
108
+ tooltipState.visible = false;
109
+ tooltipState.point = null;
110
+ }}
111
+ >
112
+ {#if tooltipState.visible && tooltipState.point}
113
+ <div
114
+ class="pointer-events-none absolute z-50 rounded border bg-white p-2 text-xs shadow-lg"
115
+ style="left: {tooltipState.x + 10}px; top: {tooltipState.y + 10}px;"
116
+ >
117
+ <div class="font-medium">ID: {tooltipState.point.metadata.id}</div>
118
+ <div>X: {tooltipState.point.value[0]}</div>
119
+ <div>Y: {tooltipState.point.value[1]}</div>
120
+ </div>
121
+ {/if}
122
+ </Scatterplot>
123
+ </div>
124
+ </Story>
125
+
126
+ <Story name="Overlapping groups" asChild>
127
+ {@const data: DataPoint[] = [
128
+ { value: [12, 9], metadata: { id: 'A' } },
129
+ { value: [12, 15], metadata: { id: 'A' } },
130
+ { value: [12, 12], metadata: { id: 'B' } },
131
+ { value: [12, 18], metadata: { id: 'B' } },
132
+ { value: [12, 14], metadata: { id: 'B' } },
133
+ { value: [31, 27], metadata: { id: 'C' } },
134
+ { value: [31, 34], metadata: { id: 'C' } },
135
+ { value: [31, 30], metadata: { id: 'D' } },
136
+ ]}
137
+ {@const groups = [
138
+ [0, 1],
139
+ [2, 3, 4],
140
+ [5, 6],
141
+ [7],
142
+ ]}
143
+ {@const lineData: [[number, number], [number, number]] = [[0, 0], [40, 40]]}
144
+ <div class="h-[400px] w-full">
145
+ <Scatterplot
146
+ {data}
147
+ {groups}
148
+ {lineData}
149
+ xAxisName="X axis"
150
+ yAxisName="Y axis"
151
+ onmouseover={(params) => {
152
+ tooltipState.visible = true;
153
+ tooltipState.x = (params.event?.event as MouseEvent)?.offsetX ?? 0;
154
+ tooltipState.y = (params.event?.event as MouseEvent)?.offsetY ?? 0;
155
+ tooltipState.point = params.data;
156
+ }}
157
+ onmouseout={() => {
158
+ tooltipState.visible = false;
159
+ tooltipState.point = null;
160
+ }}
161
+ >
162
+ {#if tooltipState.visible && tooltipState.point}
163
+ <div
164
+ class="pointer-events-none absolute z-50 rounded border bg-white p-2 text-xs shadow-lg"
165
+ style="left: {tooltipState.x + 10}px; top: {tooltipState.y + 10}px;"
166
+ >
167
+ <div class="font-medium">ID: {tooltipState.point.metadata.id}</div>
168
+ <div>X: {tooltipState.point.value[0]}</div>
169
+ <div>Y: {tooltipState.point.value[1]}</div>
170
+ </div>
171
+ {/if}
172
+ </Scatterplot>
104
173
  </div>
105
174
  </Story>
@@ -16,7 +16,6 @@
16
16
  export type DataPoint = {
17
17
  value: [number, number];
18
18
  metadata: any;
19
- error_value?: number;
20
19
  disagreement?: boolean | null;
21
20
  highlighted?: boolean | null;
22
21
  };
@@ -31,9 +30,15 @@
31
30
  type: 'point' | 'line' | 'area';
32
31
  };
33
32
 
33
+ type LineSegment = {
34
+ points: [number, number][];
35
+ disagreement?: boolean;
36
+ };
37
+
34
38
  type ScatterPlotProps = {
35
39
  data: DataPoint[];
36
40
  lineData?: [[number, number], [number, number]];
41
+ groups?: number[][];
37
42
  showConfidenceBand?: boolean;
38
43
  showLegend?: boolean;
39
44
  onitemclick?: (params: ScatterPlotEchartsEvent) => void;
@@ -58,10 +63,55 @@
58
63
  { label: 'Perfect agreement', color: backgroundColor['lilac-inverse'], type: 'line' },
59
64
  ],
60
65
  highlightIndex = null,
66
+ groups = [],
61
67
  children,
62
68
  ...props
63
69
  }: ScatterPlotProps = $props();
64
70
 
71
+ let hoveredGroupIndices = $state<number[] | null>(null);
72
+
73
+ const findGroupForIndex = (idx: number): number[] | null => {
74
+ if (groups.length === 0) return null;
75
+ return groups.find((g) => g.includes(idx)) ?? null;
76
+ };
77
+
78
+ const highlightedIndices = $derived.by((): number[] | null => {
79
+ if (hoveredGroupIndices !== null) return hoveredGroupIndices;
80
+ if (highlightIndex !== null) {
81
+ const group = findGroupForIndex(highlightIndex);
82
+ return group ?? [highlightIndex];
83
+ }
84
+ return null;
85
+ });
86
+
87
+ const groupLines = $derived.by((): LineSegment[] => {
88
+ if (groups.length === 0) return [];
89
+
90
+ return groups
91
+ .filter((g) => g.length > 1)
92
+ .map((groupIndices) => {
93
+ const points = groupIndices.map((i) => data[i]?.value).filter(Boolean) as [
94
+ number,
95
+ number,
96
+ ][];
97
+ const hasDisagreement = groupIndices.some((i) => data[i]?.disagreement);
98
+ return { points, disagreement: hasDisagreement };
99
+ });
100
+ });
101
+
102
+ const groupedDisagreement = $derived.by((): boolean[] => {
103
+ const result = data.map((d) => d.disagreement ?? false);
104
+ for (const groupIndices of groups) {
105
+ const hasDisagreement = groupIndices.some((i) => data[i]?.disagreement);
106
+ if (hasDisagreement) {
107
+ for (const i of groupIndices) {
108
+ result[i] = true;
109
+ }
110
+ }
111
+ }
112
+ return result;
113
+ });
114
+
65
115
  const displayedLegendItems: LegendItem[] = $derived([
66
116
  ...legendItems,
67
117
  ...(showConfidenceBand
@@ -82,75 +132,44 @@
82
132
  }
83
133
 
84
134
  function handleMouseOver(params: ScatterPlotEchartsEvent) {
135
+ const hoveredIdx = params.dataIndex;
136
+ if (hoveredIdx !== undefined && hoveredIdx >= 0) {
137
+ const group = findGroupForIndex(hoveredIdx);
138
+ hoveredGroupIndices = group ?? [hoveredIdx];
139
+ }
85
140
  onmouseover?.(params);
86
141
  }
87
142
 
88
143
  function handleMouseOut() {
144
+ hoveredGroupIndices = null;
89
145
  onmouseout?.();
90
146
  }
91
147
 
92
- // from https://echarts.apache.org/examples/en/editor.html?c=custom-error-bar
93
- function renderErrorBarItem(
94
- params: CustomSeriesRenderItemParams,
95
- api: CustomSeriesRenderItemAPI
96
- ) {
148
+ function renderLineItem(params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI) {
97
149
  const xValue = api.value(0) as number;
98
- const yValue = api.value(1) as number;
99
- const error = api.value(2) as number;
150
+ const yMin = api.value(1) as number;
151
+ const yMax = api.value(2) as number;
100
152
  const disagreement = api.value(3) as number;
101
153
 
102
- const highPoint = api.coord([xValue, yValue + error]);
103
- const lowPoint = api.coord([xValue, yValue - error]);
154
+ const topPoint = api.coord([xValue, yMax]);
155
+ const bottomPoint = api.coord([xValue, yMin]);
104
156
 
105
- if (!highPoint || !lowPoint) {
157
+ if (!topPoint || !bottomPoint) {
106
158
  return undefined;
107
159
  }
108
160
 
109
- // calculate a dynamic width for the caps based on the x-axis scale
110
- const sizeValue = api.size?.([1, 0]);
111
- const baseWidth = Array.isArray(sizeValue) ? sizeValue[0] : 10;
112
- const halfWidth = Math.min((baseWidth ?? 10) * 1.5, 3);
113
- const style = {
114
- stroke: disagreement ? textColor['icon-tertiary'] : backgroundColor['blue-inverse'],
115
- };
116
-
117
161
  return {
118
- type: 'group' as const,
119
- children: [
120
- {
121
- type: 'line' as const,
122
- transition: 'shape' as const,
123
- shape: {
124
- x1: highPoint[0] - halfWidth,
125
- y1: highPoint[1],
126
- x2: highPoint[0] + halfWidth,
127
- y2: highPoint[1],
128
- },
129
- style,
130
- },
131
- {
132
- type: 'line' as const,
133
- transition: 'shape' as const,
134
- shape: {
135
- x1: highPoint[0],
136
- y1: highPoint[1],
137
- x2: lowPoint[0],
138
- y2: lowPoint[1],
139
- },
140
- style,
141
- },
142
- {
143
- type: 'line' as const,
144
- transition: 'shape' as const,
145
- shape: {
146
- x1: lowPoint[0] - halfWidth,
147
- y1: lowPoint[1],
148
- x2: lowPoint[0] + halfWidth,
149
- y2: lowPoint[1],
150
- },
151
- style,
152
- },
153
- ],
162
+ type: 'line' as const,
163
+ shape: {
164
+ x1: topPoint[0],
165
+ y1: topPoint[1],
166
+ x2: bottomPoint[0],
167
+ y2: bottomPoint[1],
168
+ },
169
+ style: {
170
+ stroke: disagreement ? textColor['icon-tertiary'] : backgroundColor['blue-inverse'],
171
+ lineWidth: 1.5,
172
+ },
154
173
  };
155
174
  }
156
175
 
@@ -158,9 +177,6 @@
158
177
 
159
178
  function getChartOptions(): EChartsOption {
160
179
  const series: SeriesOption[] = [];
161
- const errorBarData = data
162
- .filter((d) => d.error_value != 0)
163
- .map((d) => [...d.value, d.error_value, d.disagreement ? 1 : 0]);
164
180
 
165
181
  if (props.lineData && props.lineData.length > 0) {
166
182
  const extendedLineData = [
@@ -226,36 +242,32 @@
226
242
  type: 'scatter',
227
243
  itemStyle: {
228
244
  color: (params: any) =>
229
- params?.data?.disagreement ? textColor['icon-tertiary'] : backgroundColor['blue-inverse'],
245
+ groupedDisagreement[params.dataIndex]
246
+ ? textColor['icon-tertiary']
247
+ : backgroundColor['blue-inverse'],
230
248
  opacity: 1,
231
249
  },
232
250
  emphasis: {
233
- itemStyle: {
234
- color: (params: any) => {
235
- const point = params?.data as DataPoint;
236
- return point?.disagreement
237
- ? textColor['icon-tertiary']
238
- : backgroundColor['blue-inverse'];
239
- },
240
- opacity: 1,
241
- },
251
+ disabled: true,
242
252
  },
243
253
  animation: false,
244
254
  });
245
255
 
246
- const p =
247
- highlightIndex !== null && highlightIndex >= 0 && highlightIndex < data.length
248
- ? data[highlightIndex]
249
- : null;
256
+ const highlightPoints =
257
+ highlightedIndices !== null
258
+ ? highlightedIndices
259
+ .filter((i) => i >= 0 && i < data.length)
260
+ .map((i) => ({ point: data[i], index: i }))
261
+ : [];
250
262
  series.push({
251
263
  id: 'highlight-overlay',
252
264
  type: 'scatter',
253
- data: [p],
265
+ data: highlightPoints.map((h) => h.point),
254
266
  symbolSize: 16,
255
267
  itemStyle: {
256
268
  color: (params: any) => {
257
- const point = params?.data as DataPoint;
258
- return point?.disagreement
269
+ const originalIndex = highlightPoints[params.dataIndex]?.index;
270
+ return groupedDisagreement[originalIndex]
259
271
  ? backgroundColor['neutral-hover']
260
272
  : backgroundColor['blue-hover'];
261
273
  },
@@ -264,16 +276,23 @@
264
276
  silent: true,
265
277
  });
266
278
 
267
- if (errorBarData.length > 0) {
279
+ if (groupLines.length > 0) {
280
+ const linesData = groupLines.map((line) => {
281
+ const yValues = line.points.map((p: [number, number]) => p[1]);
282
+ const xValue = line.points[0][0];
283
+ return [xValue, Math.min(...yValues), Math.max(...yValues), line.disagreement ? 1 : 0];
284
+ });
285
+
268
286
  series.push({
269
287
  type: 'custom',
270
- name: 'error',
288
+ name: 'lines',
271
289
  silent: true,
272
- itemStyle: { borderWidth: 1.5 },
273
- renderItem: renderErrorBarItem,
274
- data: errorBarData,
290
+ renderItem: renderLineItem,
291
+ data: linesData,
292
+ z: -1,
275
293
  });
276
294
  }
295
+
277
296
  return {
278
297
  xAxis: {
279
298
  type: 'value',
@@ -4,7 +4,6 @@ import type { Snippet } from 'svelte';
4
4
  export type DataPoint = {
5
5
  value: [number, number];
6
6
  metadata: any;
7
- error_value?: number;
8
7
  disagreement?: boolean | null;
9
8
  highlighted?: boolean | null;
10
9
  };
@@ -19,6 +18,7 @@ type LegendItem = {
19
18
  type ScatterPlotProps = {
20
19
  data: DataPoint[];
21
20
  lineData?: [[number, number], [number, number]];
21
+ groups?: number[][];
22
22
  showConfidenceBand?: boolean;
23
23
  showLegend?: boolean;
24
24
  onitemclick?: (params: ScatterPlotEchartsEvent) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reshape-biotech/design-system",
3
- "version": "2.7.37",
3
+ "version": "2.7.38",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build",