@globalbrain/sefirot 4.12.0 → 4.14.0

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.
package/config/vite.js CHANGED
@@ -57,6 +57,7 @@ export const baseConfig = {
57
57
  '@vueuse/core',
58
58
  'body-scroll-lock',
59
59
  'dayjs',
60
+ 'd3',
60
61
  'file-saver',
61
62
  'fuse.js',
62
63
  'lodash-es',
@@ -80,7 +81,8 @@ export const baseConfig = {
80
81
  'dayjs/plugin/timezone',
81
82
  'dayjs/plugin/utc',
82
83
  'markdown-it > argparse',
83
- 'markdown-it > entities'
84
+ 'markdown-it > entities',
85
+ 'file-saver'
84
86
  ],
85
87
  exclude: [
86
88
  'markdown-it'
@@ -0,0 +1,384 @@
1
+ <script setup lang="ts">
2
+ import { useElementSize } from '@vueuse/core'
3
+ import * as d3 from 'd3'
4
+ import { useTemplateRef, watch } from 'vue'
5
+ import { type ChartColor, type KV, type Margins, c, scheme } from '../support/Chart'
6
+
7
+ const props = withDefaults(defineProps<{
8
+ // State
9
+ data: KV[]
10
+
11
+ // Chart appearance
12
+ type?: 'horizontal' | 'vertical'
13
+ colors?: ChartColor[]
14
+ margins?: Margins
15
+ maxBandwidth?: number
16
+
17
+ // Axis & labels
18
+ xLabel?: string
19
+ yLabel?: string
20
+ xLabelOffset?: number
21
+ yLabelOffset?: number
22
+ xLabelFontSize?: string
23
+ yLabelFontSize?: string
24
+ ticks?: number
25
+ tickFontSize?: string
26
+
27
+ // Tooltip & interactivity
28
+ tooltip?: boolean
29
+ tooltipFormat?: (d: KV, color: string) => string
30
+
31
+ // Animation & debug
32
+ animate?: boolean
33
+ debug?: boolean
34
+ }>(), {
35
+ type: 'vertical',
36
+ colors: () => ['blue'],
37
+ maxBandwidth: 100,
38
+
39
+ xLabelFontSize: '14px',
40
+ yLabelFontSize: '14px',
41
+ ticks: 5,
42
+ tickFontSize: '14px',
43
+
44
+ tooltip: true,
45
+ tooltipFormat: (d: KV) => `${d.key} – ${d.value}`,
46
+
47
+ animate: true,
48
+ debug: false
49
+ })
50
+
51
+ const chartRef = useTemplateRef('chart')
52
+ const { width, height } = useElementSize(chartRef)
53
+
54
+ let tooltipTimeout = 0
55
+
56
+ // Function to render the chart
57
+ function renderChart({
58
+ clientWidth,
59
+ clientHeight,
60
+ animate
61
+ }: {
62
+ clientWidth: number
63
+ clientHeight: number
64
+ animate: boolean
65
+ }) {
66
+ if (!chartRef.value) { return }
67
+
68
+ // Create color scale
69
+ const color = scheme(props.data, props.colors)
70
+
71
+ // Clear any existing SVG
72
+ d3
73
+ .select(chartRef.value)
74
+ .selectAll('*')
75
+ .remove()
76
+
77
+ // Set dimensions and margins
78
+ const vertical = props.type === 'vertical'
79
+
80
+ const margin = {
81
+ top: props.margins?.top ?? 30,
82
+ right: props.margins?.right ?? 40,
83
+ bottom: props.margins?.bottom ?? (props.xLabel ? 80 : 60),
84
+ left: props.margins?.left ?? (props.yLabel ? (vertical ? 80 : 100) : (vertical ? 60 : 80))
85
+ }
86
+
87
+ const width = clientWidth - margin.left - margin.right
88
+ const height = clientHeight - margin.top - margin.bottom
89
+
90
+ const xLabelOffset = props.xLabelOffset ?? 46
91
+ const yLabelOffset = props.yLabelOffset ?? (vertical ? 40 : 56)
92
+
93
+ // Create SVG
94
+ const svg = d3
95
+ .select(chartRef.value)
96
+ .append('svg')
97
+ .attr('width', '100%')
98
+ .attr('height', height + margin.top + margin.bottom)
99
+ .append('g')
100
+ .attr('transform', `translate(${margin.left},${margin.top})`)
101
+
102
+ // Create a padded scale for the bars (with padding for visual spacing)
103
+ const paddedScale = d3
104
+ .scaleBand<string>()
105
+ .domain(props.data.map((d) => d.key))
106
+ .range(vertical ? [0, width] : [0, height])
107
+ .padding(0.4)
108
+
109
+ // Y scale for bar values
110
+ const y = d3
111
+ .scaleLinear()
112
+ .domain([0, d3.max(props.data, (d) => d.value)!])
113
+ .nice()
114
+ .range(vertical ? [height, 0] : [0, width])
115
+
116
+ // Compute a constant offset to center the colored bar inside its full band.
117
+ const groupOffset = (paddedScale.step() - paddedScale.bandwidth()) / 2
118
+ const heightPadding = 24
119
+ const bandwidth = Math.min(paddedScale.bandwidth(), props.maxBandwidth)
120
+ const innerOffset = (paddedScale.bandwidth() - bandwidth) / 2
121
+
122
+ // For the axes, use the paddedScale so ticks remain centered on the bars.
123
+ svg
124
+ .append('g')
125
+ .attr('transform', `translate(0,${height})`)
126
+ .call(vertical ? d3.axisBottom(paddedScale) : d3.axisBottom(y).ticks(props.ticks))
127
+ .selectAll('text')
128
+ .attr('fill', c.text2)
129
+ .style('font-size', props.tickFontSize)
130
+ .style('text-anchor', 'middle')
131
+
132
+ // Remove X axis line
133
+ svg
134
+ .select('.domain')
135
+ .remove()
136
+
137
+ // Add Y axis
138
+ svg
139
+ .append('g')
140
+ .call(vertical ? d3.axisLeft(y).ticks(props.ticks) : d3.axisLeft(paddedScale))
141
+ .selectAll('text')
142
+ .attr('fill', c.text2)
143
+ .style('font-size', props.tickFontSize)
144
+
145
+ // Remove Y axis line
146
+ svg
147
+ .select('.domain')
148
+ .remove()
149
+
150
+ // Add horizontal grid lines
151
+ const gridLines = svg
152
+ .selectAll()
153
+ .data(y.ticks(props.ticks))
154
+ .enter()
155
+ .append('line')
156
+ .attr('stroke', c.divider)
157
+ .attr('stroke-dasharray', '2,2')
158
+
159
+ if (vertical) {
160
+ gridLines
161
+ .attr('x1', 0)
162
+ .attr('x2', width)
163
+ .attr('y1', (d) => y(d))
164
+ .attr('y2', (d) => y(d))
165
+ } else {
166
+ gridLines
167
+ .attr('x1', (d) => y(d))
168
+ .attr('x2', (d) => y(d))
169
+ .attr('y1', 0)
170
+ .attr('y2', height)
171
+ }
172
+
173
+ // Add axis labels
174
+ if (props.xLabel) {
175
+ svg
176
+ .append('text')
177
+ .attr('x', width / 2)
178
+ .attr('y', height + xLabelOffset)
179
+ .attr('fill', c.text2)
180
+ .style('font-size', props.xLabelFontSize)
181
+ .style('text-anchor', 'middle')
182
+ .html(props.xLabel)
183
+ }
184
+ if (props.yLabel) {
185
+ svg
186
+ .append('text')
187
+ .attr('x', -height / 2)
188
+ .attr('y', -yLabelOffset)
189
+ .attr('transform', 'rotate(-90)')
190
+ .attr('fill', c.text2)
191
+ .style('font-size', props.yLabelFontSize)
192
+ .style('text-anchor', 'middle')
193
+ .html(props.yLabel)
194
+ }
195
+
196
+ // Add bars
197
+ const barGroups = svg
198
+ .selectAll()
199
+ .data(props.data)
200
+ .enter()
201
+ .append('g')
202
+ .attr('transform', (d) =>
203
+ vertical
204
+ ? `translate(${(paddedScale(d.key)! - groupOffset)},0)`
205
+ : `translate(0,${(paddedScale(d.key)! - groupOffset)})`
206
+ )
207
+
208
+ // Each group gets a transparent rect covering the full band (using paddedScale.step())
209
+ const outerBars = barGroups
210
+ .append('rect')
211
+ .attr('fill', 'transparent')
212
+ .attr('pointer-events', 'all')
213
+
214
+ // Append the colored bar rect inside each group.
215
+ // We now offset it by groupOffset so its left edge is at paddedScale(d.key)
216
+ const bars = barGroups
217
+ .append('rect')
218
+ .attr('fill', (d) => color(d))
219
+ .attr('rx', 2)
220
+ .attr('ry', 2)
221
+
222
+ if (!animate) {
223
+ if (vertical) {
224
+ outerBars
225
+ .attr('x', 0)
226
+ .attr('y', (d) => Math.max(0, y(d.value) - heightPadding))
227
+ .attr('width', paddedScale.step())
228
+ .attr('height', (d) => height - Math.max(0, y(d.value) - heightPadding))
229
+ bars
230
+ .attr('x', groupOffset + innerOffset)
231
+ .attr('y', (d) => y(d.value))
232
+ .attr('width', bandwidth)
233
+ .attr('height', (d) => height - y(d.value))
234
+ } else {
235
+ outerBars
236
+ .attr('x', 0)
237
+ .attr('y', 0)
238
+ .attr('width', (d) => Math.min(width, y(d.value) + heightPadding))
239
+ .attr('height', paddedScale.step())
240
+ bars
241
+ .attr('x', 0)
242
+ .attr('y', groupOffset + innerOffset)
243
+ .attr('width', (d) => y(d.value))
244
+ .attr('height', bandwidth)
245
+ }
246
+ } else {
247
+ // Animate the bars
248
+ if (vertical) {
249
+ outerBars
250
+ .attr('x', 0)
251
+ .attr('y', height)
252
+ .attr('width', paddedScale.step())
253
+ .attr('height', 0)
254
+ .transition()
255
+ .duration(800)
256
+ .delay((_, i) => i * 100)
257
+ .attr('y', (d) => Math.max(0, y(d.value) - heightPadding))
258
+ .attr('height', (d) => height - Math.max(0, y(d.value) - heightPadding))
259
+ bars
260
+ .attr('x', groupOffset + innerOffset)
261
+ .attr('y', height)
262
+ .attr('width', bandwidth)
263
+ .attr('height', 0)
264
+ .transition()
265
+ .duration(800)
266
+ .delay((_, i) => i * 100)
267
+ .attr('y', (d) => y(d.value))
268
+ .attr('height', (d) => height - y(d.value))
269
+ } else {
270
+ outerBars
271
+ .attr('x', 0)
272
+ .attr('y', 0)
273
+ .attr('width', 0)
274
+ .attr('height', paddedScale.step())
275
+ .transition()
276
+ .duration(800)
277
+ .delay((_, i) => i * 100)
278
+ .attr('width', (d) => Math.min(width, y(d.value) + heightPadding))
279
+ bars
280
+ .attr('x', 0)
281
+ .attr('y', groupOffset + innerOffset)
282
+ .attr('width', 0)
283
+ .attr('height', bandwidth)
284
+ .transition()
285
+ .duration(800)
286
+ .delay((_, i) => i * 100)
287
+ .attr('width', (d) => y(d.value))
288
+ }
289
+ }
290
+
291
+ if (props.tooltip) {
292
+ const Tooltip = d3
293
+ .select(chartRef.value)
294
+ .append('div')
295
+ .attr('class', 'tooltip')
296
+
297
+ function updatePos(event: PointerEvent) {
298
+ const [x, y] = d3.pointer(event, chartRef.value)
299
+ Tooltip
300
+ .style('left', `${x + 14}px`)
301
+ .style('top', `${y + 14}px`)
302
+ }
303
+
304
+ barGroups
305
+ .on('pointerenter', (event: PointerEvent, d) => {
306
+ window.clearTimeout(tooltipTimeout)
307
+ Tooltip
308
+ .html(props.tooltipFormat(d, color(d)))
309
+ .style('opacity', '1')
310
+ updatePos(event)
311
+ })
312
+ .on('pointermove', updatePos)
313
+ .on('pointerleave', () => {
314
+ window.clearTimeout(tooltipTimeout)
315
+ tooltipTimeout = window.setTimeout(() => {
316
+ Tooltip
317
+ .style('opacity', '0')
318
+ }, 400)
319
+ })
320
+ }
321
+
322
+ // Render outline for debugging
323
+ if (props.debug) {
324
+ d3
325
+ .select(chartRef.value)
326
+ .append('div')
327
+ .style('position', 'absolute')
328
+ .style('top', `${margin.top}px`)
329
+ .style('left', `${margin.left}px`)
330
+ .style('width', `${width}px`)
331
+ .style('height', `${height}px`)
332
+ .style('outline', '1px solid blue')
333
+ .style('pointer-events', 'none')
334
+ outerBars
335
+ .style('outline', '1px solid green')
336
+ }
337
+ }
338
+
339
+ watch(
340
+ [width, height, () => props],
341
+ ([clientWidth, clientHeight], [oldWidth, oldHeight]) => {
342
+ if (!clientWidth || !clientHeight) { return }
343
+ renderChart({
344
+ clientWidth,
345
+ clientHeight,
346
+ animate:
347
+ props.animate
348
+ && ((oldWidth === 0 && oldHeight === 0)
349
+ || (clientHeight === oldHeight && clientWidth === oldWidth))
350
+ })
351
+ },
352
+ { immediate: true, deep: true }
353
+ )
354
+ </script>
355
+
356
+ <template>
357
+ <div ref="chart" class="SChartBar" />
358
+ </template>
359
+
360
+ <style scoped lang="postcss">
361
+ .SChartBar {
362
+ position: relative;
363
+ width: 100%;
364
+ height: 100%;
365
+ font-feature-settings: 'tnum' 1;
366
+ }
367
+
368
+ :deep(.tooltip) {
369
+ opacity: 0;
370
+ pointer-events: none;
371
+ position: absolute;
372
+ top: 0;
373
+ left: 0;
374
+ padding: 2px 8px;
375
+ background-color: var(--c-bg-elv-2);
376
+ border: 1px solid var(--c-divider);
377
+ border-radius: 6px;
378
+ font-size: 12px;
379
+ }
380
+
381
+ :deep(.tick line) {
382
+ display: none;
383
+ }
384
+ </style>
@@ -0,0 +1,379 @@
1
+ <script setup lang="ts">
2
+ import { useElementSize } from '@vueuse/core'
3
+ import * as d3 from 'd3'
4
+ import { useTemplateRef, watch } from 'vue'
5
+ import { type ChartColor, type KV, type Margins, c, scheme } from '../support/Chart'
6
+
7
+ const props = withDefaults(defineProps<{
8
+ // State
9
+ data: KV[]
10
+ activeKey?: string
11
+
12
+ // Chart appearance
13
+ type?: 'pie' | 'donut'
14
+ half?: boolean
15
+ colors?: ChartColor[]
16
+ margins?: Margins
17
+ innerRadius?: (outerRadius: number) => number
18
+
19
+ // Labels
20
+ labels?: boolean
21
+ labelFormat?: (d: KV) => string
22
+ labelFontSize?: string
23
+
24
+ // Legend
25
+ legend?: boolean
26
+ legendFormatKey?: (d: KV) => string
27
+ legendFormatValue?: (d: KV) => string
28
+ legendPadding?: number
29
+ legendFontSize?: string
30
+
31
+ // Tooltip & interactivity
32
+ tooltip?: boolean
33
+ tooltipFormat?: (d: KV, color: string) => string
34
+
35
+ // Animation & debug
36
+ animate?: boolean
37
+ debug?: boolean
38
+ }>(), {
39
+ type: 'donut',
40
+ half: false,
41
+
42
+ labels: false,
43
+ labelFormat: (d: KV) => `${d.key} – ${d.value}`,
44
+ labelFontSize: '14px',
45
+
46
+ legend: true,
47
+ legendFormatKey: (d: KV) => d.key,
48
+ legendFormatValue: (d: KV) => d.value.toString(),
49
+ legendPadding: 70,
50
+ legendFontSize: '14px',
51
+
52
+ tooltip: true,
53
+ tooltipFormat: (d: KV) => `${d.key} – ${d.value}`,
54
+
55
+ animate: true,
56
+ debug: false
57
+ })
58
+
59
+ const chartRef = useTemplateRef('chart')
60
+ const { width, height } = useElementSize(chartRef)
61
+
62
+ let tooltipTimeout = 0
63
+
64
+ // Function to render the chart
65
+ function renderChart({
66
+ clientWidth,
67
+ clientHeight,
68
+ animate
69
+ }: {
70
+ clientWidth: number
71
+ clientHeight: number
72
+ animate: boolean
73
+ }) {
74
+ if (!chartRef.value) { return }
75
+
76
+ // Create color scale
77
+ const color = scheme(props.data, props.colors)
78
+
79
+ // Clear any existing SVG
80
+ d3
81
+ .select(chartRef.value)
82
+ .selectAll('*')
83
+ .remove()
84
+
85
+ // Set dimensions and margins
86
+ const margin = {
87
+ top: props.margins?.top ?? 30,
88
+ right: props.margins?.right ?? 30,
89
+ bottom: props.margins?.bottom ?? 30,
90
+ left: props.margins?.left ?? 30
91
+ }
92
+ const width = clientWidth - margin.left - margin.right
93
+ const height = clientHeight - margin.top - margin.bottom
94
+
95
+ const width_2 = width / 2
96
+ const height_2 = props.half ? height : height / 2
97
+
98
+ // Create SVG
99
+ const svg = d3
100
+ .select(chartRef.value)
101
+ .append('svg')
102
+ .attr('width', '100%')
103
+ .attr('height', clientHeight)
104
+ .append('g')
105
+
106
+ // Prepare data
107
+ const pie = d3
108
+ .pie<KV>()
109
+ .value((d) => d.value)
110
+ .sort(null)
111
+ .padAngle(0.02)
112
+
113
+ if (props.half) {
114
+ pie.startAngle(-Math.PI / 2)
115
+ pie.endAngle(Math.PI / 2)
116
+ }
117
+
118
+ const dataReady = pie(props.data)
119
+
120
+ // Render legend
121
+ let legendGroup
122
+ let legendHeight = 0
123
+ let legendWidth = 0
124
+ if (props.legend) {
125
+ // Create a group for the legend
126
+ legendGroup = svg
127
+ .append('g')
128
+
129
+ // Add legend
130
+ const legend = legendGroup
131
+ .selectAll()
132
+ .data(dataReady)
133
+ .enter()
134
+ .append('g')
135
+ .attr('transform', (d, i) => `translate(0,${i * 24})`)
136
+ .style('font-size', props.legendFontSize)
137
+
138
+ // Add colored rectangles to the legend
139
+ legend
140
+ .append('rect')
141
+ .attr('x', 0)
142
+ .attr('rx', 2)
143
+ .attr('ry', 2)
144
+ .attr('width', 14)
145
+ .attr('height', 14)
146
+ .attr('fill', (d) => color(d.data))
147
+
148
+ // Add legend text
149
+ legend
150
+ .append('text')
151
+ .attr('x', 30)
152
+ .attr('y', 14 / 2)
153
+ .attr('dy', '0.35em')
154
+ .attr('fill', c.text2)
155
+ .html((d) => props.legendFormatKey(d.data))
156
+
157
+ // Show value next to the legend
158
+ legend
159
+ .append('text')
160
+ .attr('x', 30 + props.legendPadding)
161
+ .attr('y', 14 / 2)
162
+ .attr('dy', '0.35em')
163
+ .attr('fill', c.text1)
164
+ .attr('text-anchor', 'end')
165
+ .html((d) => props.legendFormatValue(d.data))
166
+
167
+ ;({ width: legendWidth = 0, height: legendHeight = 0 } = legendGroup?.node()?.getBBox() ?? {})
168
+
169
+ // Animate the legends
170
+ if (animate) {
171
+ legend
172
+ .attr('opacity', 0)
173
+ .transition()
174
+ .delay((d, i) => i * 100)
175
+ .duration(800)
176
+ .attr('opacity', 1)
177
+ }
178
+ }
179
+
180
+ // Calculate radius and center the chart
181
+ const r_k = props.half ? 0.25 : 0.5
182
+ const radius = Math.min(height_2, (width - legendWidth) / (2 + (props.legend ? r_k : 0)))
183
+ const innerRadius = props.innerRadius?.(radius) ?? (props.type === 'pie' ? 6 : Math.max(radius / 1.5, radius - 50))
184
+
185
+ legendGroup
186
+ ?.attr('transform', `translate(${radius * (1 + r_k)},${-(props.half ? height_2 : 0) / 2 - legendHeight / 2})`)
187
+ svg
188
+ .attr('transform', `translate(${margin.left + width_2 - (props.legend ? radius * r_k + legendWidth : 0) / 2},${margin.top + height_2})`)
189
+
190
+ // Create arc generator
191
+ const arc = d3
192
+ .arc<d3.PieArcDatum<KV>>()
193
+ .innerRadius(innerRadius)
194
+ .outerRadius(radius)
195
+ .cornerRadius(2)
196
+
197
+ // Add the arcs
198
+ const transform = `translate(0,${-(height_2 - radius) / 2})`
199
+ const activeTransform = `${transform} scale(1.05)`
200
+
201
+ const arcs = svg
202
+ .selectAll()
203
+ .data(dataReady)
204
+ .join('path')
205
+ .attr('fill', (d) => color(d.data))
206
+ .attr('transform', transform)
207
+
208
+ const activeArcs = arcs
209
+ .filter((d) => d.data.key === props.activeKey)
210
+
211
+ if (!animate) {
212
+ arcs
213
+ .attr('d', arc)
214
+
215
+ activeArcs
216
+ .attr('transform', activeTransform)
217
+ } else {
218
+ // Animate the arcs
219
+ arcs
220
+ .attr('d', arc({ startAngle: 0, endAngle: 0, data: {} } as any))
221
+ .transition()
222
+ .duration(800 + props.data.length * 100)
223
+ .attrTween('d', (d) => {
224
+ const startAngle = props.half ? -Math.PI / 2 : 0
225
+ const i = d3.interpolate({ startAngle, endAngle: startAngle }, d)
226
+ return (t) => arc(i(t))!
227
+ })
228
+ .end()
229
+ .then(() => {
230
+ activeArcs
231
+ .transition()
232
+ .duration(200)
233
+ .attr('transform', activeTransform)
234
+ })
235
+ }
236
+
237
+ if (props.labels) {
238
+ const labelArc = d3.arc<d3.PieArcDatum<KV>>()
239
+ .innerRadius(radius)
240
+ .outerRadius(radius)
241
+
242
+ const labels = svg
243
+ .selectAll()
244
+ .data(dataReady)
245
+ .enter()
246
+ .append('g')
247
+ .attr('transform', transform)
248
+
249
+ const leftOrRight = (d: d3.PieArcDatum<KV>) => {
250
+ const midAngle = (d.startAngle + d.endAngle) / 2
251
+ if (props.half) {
252
+ return midAngle < 0 ? -1 : 1
253
+ }
254
+ return midAngle < Math.PI ? 1 : -1
255
+ }
256
+
257
+ labels
258
+ .append('polyline')
259
+ .attr('stroke', c.divider)
260
+ .attr('fill', 'none')
261
+ .attr('points', (d) => {
262
+ const posA = arc.centroid(d)
263
+ const posB = labelArc.centroid(d)
264
+ const posC = labelArc.centroid(d)
265
+ posC[0] = radius * 1.05 * leftOrRight(d)
266
+ return [posA, posB, posC].map((p) => p.join(','))
267
+ })
268
+
269
+ labels
270
+ .append('text')
271
+ .attr('transform', (d) => {
272
+ const pos = labelArc.centroid(d)
273
+ pos[0] = radius * 1.1 * leftOrRight(d)
274
+ return `translate(${pos})`
275
+ })
276
+ .attr('dy', '0.35em')
277
+ .attr('fill', c.text2)
278
+ .attr('text-anchor', (d) => leftOrRight(d) === 1 ? 'start' : 'end')
279
+ .style('font-size', props.labelFontSize)
280
+ .html((d) => props.labelFormat(d.data))
281
+
282
+ if (animate) {
283
+ labels
284
+ .attr('opacity', 0)
285
+ .transition()
286
+ .delay((_, i) => 800 + i * 100)
287
+ .duration(500)
288
+ .attr('opacity', 1)
289
+ }
290
+ }
291
+
292
+ if (props.tooltip) {
293
+ const Tooltip = d3
294
+ .select(chartRef.value)
295
+ .append('div')
296
+ .attr('class', 'tooltip')
297
+
298
+ function updatePos(event: PointerEvent) {
299
+ const [x, y] = d3.pointer(event, chartRef.value)
300
+ Tooltip
301
+ .style('left', `${x + 14}px`)
302
+ .style('top', `${y + 14}px`)
303
+ }
304
+
305
+ arcs
306
+ .on('pointerenter', (event: PointerEvent, { data: d }) => {
307
+ window.clearTimeout(tooltipTimeout)
308
+ Tooltip
309
+ .html(props.tooltipFormat(d, color(d)))
310
+ .style('opacity', '1')
311
+ updatePos(event)
312
+ })
313
+ .on('pointermove', updatePos)
314
+ .on('pointerleave', () => {
315
+ window.clearTimeout(tooltipTimeout)
316
+ tooltipTimeout = window.setTimeout(() => {
317
+ Tooltip
318
+ .style('opacity', '0')
319
+ }, 400)
320
+ })
321
+ }
322
+
323
+ // Render outline for debugging
324
+ if (props.debug) {
325
+ d3
326
+ .select(chartRef.value)
327
+ .append('div')
328
+ .style('position', 'absolute')
329
+ .style('top', `${margin.top}px`)
330
+ .style('left', `${margin.left}px`)
331
+ .style('width', `${width}px`)
332
+ .style('height', `${height}px`)
333
+ .style('outline', '1px solid blue')
334
+ .style('pointer-events', 'none')
335
+ }
336
+ }
337
+
338
+ watch(
339
+ [width, height, () => props],
340
+ ([clientWidth, clientHeight], [oldWidth, oldHeight]) => {
341
+ if (!clientWidth || !clientHeight) { return }
342
+ renderChart({
343
+ clientWidth,
344
+ clientHeight,
345
+ animate:
346
+ props.animate
347
+ && ((oldWidth === 0 && oldHeight === 0)
348
+ || (clientHeight === oldHeight && clientWidth === oldWidth))
349
+ })
350
+ },
351
+ { immediate: true, deep: true }
352
+ )
353
+ </script>
354
+
355
+ <template>
356
+ <div ref="chart" class="SChartPie" />
357
+ </template>
358
+
359
+ <style scoped lang="postcss">
360
+ .SChartPie {
361
+ position: relative;
362
+ width: 100%;
363
+ height: 100%;
364
+ font-feature-settings: 'tnum' 1;
365
+ }
366
+
367
+ :deep(.tooltip) {
368
+ opacity: 0;
369
+ pointer-events: none;
370
+ position: absolute;
371
+ top: 0;
372
+ left: 0;
373
+ padding: 2px 8px;
374
+ background-color: var(--c-bg-elv-2);
375
+ border: 1px solid var(--c-divider);
376
+ border-radius: 6px;
377
+ font-size: 12px;
378
+ }
379
+ </style>
@@ -87,7 +87,7 @@ const cleanups = [
87
87
  setTimeout(() => { ignore.value = false })
88
88
  }
89
89
  }),
90
- onClickOutside(root, hide, { ignore: [content] }),
90
+ onClickOutside(root, hide, { ignore: [content], controls: false }),
91
91
  () => timeoutId.value != null && window.clearTimeout(timeoutId.value)
92
92
  ]
93
93
 
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Adapted from
3
- * @see https://github.com/vuejs/core/blob/8d606c44ece5a9940ede05513f844f698d082589/packages/runtime-core/src/component.ts
4
- * @see https://github.com/vuejs/core/blob/cdb1d1795d21591659e2560e201ee4fbf1ea4aca/packages/runtime-core/src/warning.ts
5
- * @see https://github.com/getsentry/sentry-javascript/blob/2cfb0ef3fa5c40f90c317267a4d10b969994d021/packages/vue/src/errorhandler.ts
6
- * @see https://github.com/vercel/ai/blob/d544886d4f61440bacd6e44c86144bfac7c98282/packages/provider-utils/src/get-error-message.ts#L12
3
+ * @see https://github.com/vuejs/core/blob/1755ac0a108ba3486bd8397e56d3bdcd69196594/packages/runtime-core/src/component.ts
4
+ * @see https://github.com/vuejs/core/blob/ac9e7e8bfa55432a73a10864805fdf48bda2ff73/packages/runtime-core/src/warning.ts
5
+ * @see https://github.com/getsentry/sentry-javascript/blob/04711c20246f7cdaac2305286fec783ab1859a18/packages/vue/src/errorhandler.ts
6
+ * @see https://github.com/vercel/ai/blob/d544886d4f61440bacd6e44c86144bfac7c98282/packages/provider-utils/src/get-error-message.ts
7
7
  *
8
8
  * Original licenses:
9
9
  *
10
10
  * (c) 2018-present Yuxi (Evan) You and Vue contributors
11
11
  * @license MIT
12
12
  *
13
- * (c) 2019 Sentry (https://sentry.io) and individual contributors
13
+ * (c) 2012-2024 Functional Software, Inc. dba Sentry
14
14
  * @license MIT
15
15
  *
16
16
  * (c) 2023 Vercel, Inc.
@@ -18,7 +18,9 @@
18
18
  */
19
19
 
20
20
  import * as Sentry from '@sentry/browser'
21
+ import { createSentryPiniaPlugin } from '@sentry/vue'
21
22
  import { pauseTracking, resetTracking } from '@vue/reactivity'
23
+ import { getActivePinia } from 'pinia'
22
24
  import {
23
25
  type ComponentInternalInstance,
24
26
  type ComponentOptions,
@@ -115,11 +117,8 @@ function formatTrace(instance: ComponentInternalInstance | null): string {
115
117
  }
116
118
 
117
119
  function formatTraceEntry({ vnode, recurseCount }: TraceEntry): string {
118
- return `at <${formatComponentName(vnode.component)}${
119
- vnode.props ? ` ${formatProps(vnode.props)}` : ''
120
- }>${
121
- recurseCount > 0 ? ` ... (${recurseCount} recursive call${recurseCount > 1 ? 's' : ''})` : ''
122
- }`
120
+ return `at <${formatComponentName(vnode.component)}${vnode.props ? ` ${formatProps(vnode.props)}` : ''}>\
121
+ ${recurseCount > 0 ? ` ... (${recurseCount} recursive call${recurseCount > 1 ? 's' : ''})` : ''}`
123
122
  }
124
123
 
125
124
  function formatProps(props: Record<string, unknown>): string {
@@ -183,6 +182,7 @@ export function useErrorHandler({
183
182
 
184
183
  if (enabled) {
185
184
  Sentry.init({ dsn, environment, ignoreErrors })
185
+ getActivePinia()?.use(createSentryPiniaPlugin())
186
186
  }
187
187
 
188
188
  return function errorHandler(
@@ -190,8 +190,6 @@ export function useErrorHandler({
190
190
  instance: ComponentPublicInstance | null = null,
191
191
  info: string = ''
192
192
  ) {
193
- set(e)
194
-
195
193
  if (enabled) {
196
194
  pauseTracking()
197
195
 
@@ -222,6 +220,8 @@ export function useErrorHandler({
222
220
  } else {
223
221
  console.error(e)
224
222
  }
223
+
224
+ set(e)
225
225
  }
226
226
  }
227
227
 
@@ -1,4 +1,4 @@
1
- import { inject, provide } from 'vue'
1
+ import { type InjectionKey, getCurrentInstance, inject } from 'vue'
2
2
 
3
3
  export type Lang = 'en' | 'ja'
4
4
 
@@ -15,7 +15,7 @@ export interface HasLang {
15
15
  lang: Lang
16
16
  }
17
17
 
18
- export const SefirotLangKey = 'sefirot-lang-key'
18
+ export const SefirotLangKey: InjectionKey<Lang> = Symbol.for('sefirot-lang-key')
19
19
 
20
20
  export function useSetupLang(): (user?: HasLang | null) => void {
21
21
  const browserLang = useBrowserLang()
@@ -26,7 +26,7 @@ export function useSetupLang(): (user?: HasLang | null) => void {
26
26
  }
27
27
 
28
28
  export function provideLang(lang: Lang) {
29
- provide(SefirotLangKey, lang)
29
+ getCurrentInstance()?.appContext.app.provide(SefirotLangKey, lang)
30
30
  }
31
31
 
32
32
  export function useLang(): Lang {
package/lib/http/Http.ts CHANGED
@@ -2,7 +2,7 @@ import { parse as parseContentDisposition } from '@tinyhttp/content-disposition'
2
2
  import { parse as parseCookie } from '@tinyhttp/cookie'
3
3
  import FileSaver from 'file-saver'
4
4
  import { FetchError, type FetchOptions, type FetchRequest, type FetchResponse, ofetch } from 'ofetch'
5
- import { stringify } from 'qs'
5
+ import { type IStringifyOptions, stringify } from 'qs'
6
6
  import { type Lang } from '../composables/Lang'
7
7
  import { isBlob, isError, isFormData, isRequest, isResponse, isString } from '../support/Utils'
8
8
 
@@ -20,6 +20,7 @@ export interface HttpOptions {
20
20
  lang?: Lang
21
21
  payloadKey?: string
22
22
  headers?: () => Awaitable<Record<string, string>>
23
+ stringifyOptions?: IStringifyOptions
23
24
  }
24
25
 
25
26
  export class Http {
@@ -29,6 +30,7 @@ export class Http {
29
30
  private static lang: Lang | undefined = undefined
30
31
  private static payloadKey = '__payload__'
31
32
  private static headers: () => Awaitable<Record<string, string>> = async () => ({})
33
+ private static stringifyOptions: IStringifyOptions = {}
32
34
 
33
35
  static config(options: HttpOptions): void {
34
36
  if (options.baseUrl) {
@@ -49,6 +51,9 @@ export class Http {
49
51
  if (options.headers) {
50
52
  Http.headers = options.headers
51
53
  }
54
+ if (options.stringifyOptions) {
55
+ Http.stringifyOptions = options.stringifyOptions
56
+ }
52
57
  }
53
58
 
54
59
  private async ensureXsrfToken(): Promise<string | undefined> {
@@ -69,7 +74,8 @@ export class Http {
69
74
  private async buildRequest(url: string, _options: FetchOptions = {}): Promise<[string, FetchOptions]> {
70
75
  const { method, params, query, ...options } = _options
71
76
  const xsrfToken = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '') && (await this.ensureXsrfToken())
72
- const queryString = stringify({ ...params, ...query }, { encodeValuesOnly: true })
77
+
78
+ const queryString = stringify({ ...params, ...query }, { encodeValuesOnly: true, ...Http.stringifyOptions })
73
79
 
74
80
  return [
75
81
  `${url}${queryString ? `?${queryString}` : ''}`,
@@ -195,16 +201,10 @@ export class Http {
195
201
  export function isFetchError(e: unknown): e is FetchError {
196
202
  return (
197
203
  e instanceof FetchError
198
- || (isError(e)
199
- && (isString((e as FetchError).request) || isRequest((e as FetchError).request))
200
- && ((e as FetchError).response === undefined || isResponse((e as FetchError).response))
201
- && e.message.startsWith(
202
- `[${
203
- ((e as FetchError).request as Request | undefined)?.method || (e as FetchError).options?.method || 'GET'
204
- }] ${JSON.stringify(
205
- ((e as FetchError).request as Request | undefined)?.url || String((e as FetchError).request) || '/'
206
- )}: `
207
- ))
204
+ || (isError<FetchError>(e)
205
+ && (e.response === undefined || isResponse(e.response))
206
+ && ((isString(e.request) && e.message.startsWith(`[${e.options?.method || 'GET'}] ${JSON.stringify(e.request || '/')}: `))
207
+ || (isRequest(e.request) && e.message.startsWith(`[${e.request.method}] ${JSON.stringify(e.request.url)}: `))))
208
208
  )
209
209
  }
210
210
 
@@ -4,6 +4,14 @@
4
4
  box-sizing: border-box;
5
5
  }
6
6
 
7
+ :root:not(.dark):not(.htw-dark) {
8
+ color-scheme: light;
9
+ }
10
+
11
+ :root.dark {
12
+ color-scheme: dark;
13
+ }
14
+
7
15
  body {
8
16
  width: 100%;
9
17
  min-width: 320px;
@@ -155,6 +155,15 @@
155
155
  .s-max-w-lg { max-width: 960px; }
156
156
  .s-max-w-xl { max-width: 1216px; }
157
157
 
158
+ .s-h-256 { height: 256px; }
159
+ .s-h-320 { height: 320px; }
160
+ .s-h-512 { height: 512px; }
161
+ .s-h-full { height: 100%; }
162
+
163
+ .s-aspect-16-9 { aspect-ratio: 16 / 9; }
164
+ .s-aspect-4-3 { aspect-ratio: 4 / 3; }
165
+ .s-aspect-1-1 { aspect-ratio: 1 / 1; }
166
+
158
167
  /**
159
168
  * Typography
160
169
  * -------------------------------------------------------------------------- */
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Credits:
3
+ *
4
+ * - @radix-ui/colors - MIT License
5
+ * Copyright (c) 2021 Radix
6
+ * https://github.com/radix-ui/colors/blob/main/LICENSE
7
+ */
8
+
9
+ import FileSaver from 'file-saver'
10
+ import html2canvas from 'html2canvas'
11
+
12
+ export const c = {
13
+ text1: 'light-dark(#1c2024, #edeef0)',
14
+ text2: 'light-dark(#5d616b, #abafb7)',
15
+ divider: 'light-dark(#e0e0e1, #2e3035)'
16
+ }
17
+
18
+ // radixLight.9, radixDark.10
19
+ export const chartColors = {
20
+ // orange: 'light-dark(#f76b15, #ff801f)',
21
+ tomato: 'light-dark(#e54d2e, #ec6142)',
22
+ // red: 'light-dark(#e5484d, #ec5d5e)',
23
+ ruby: 'light-dark(#e54666, #ec5a72)',
24
+ // crimson: 'light-dark(#e93d82, #ee518a)',
25
+ pink: 'light-dark(#d6409f, #de51a8)',
26
+ // plum: 'light-dark(#ab4aba, #b658c4)',
27
+ purple: 'light-dark(#8e4ec6, #9a5cd0)',
28
+ // violet: 'light-dark(#6e56cf, #7d66d9)',
29
+ iris: 'light-dark(#5b5bd6, #6e6ade)',
30
+ // indigo: 'light-dark(#3e63dd, #5472e4)',
31
+ blue: 'light-dark(#0090ff, #3b9eff)',
32
+ // cyan: 'light-dark(#00a2c7, #23afd0)',
33
+ teal: 'light-dark(#12a594, #0eb39e)',
34
+ // jade: 'light-dark(#29a383, #27b08b)',
35
+ green: 'light-dark(#30a46c, #33b074)',
36
+ // grass: 'light-dark(#46a758, #53b365)',
37
+ gray: 'light-dark(#8d8d8d, #7b7b7b)'
38
+ } as const
39
+
40
+ // eslint-disable-next-line @typescript-eslint/ban-types
41
+ export type ChartColor = keyof typeof chartColors | (string & {})
42
+ export type KV = { key: string; value: number; color?: ChartColor }
43
+ export type Margins = Partial<{ top: number; right: number; bottom: number; left: number }>
44
+
45
+ export function getColor(...colors: (ChartColor | string | null | undefined)[]): string {
46
+ const color = colors.find((color) => color != null)
47
+ return chartColors[color as keyof typeof chartColors] || color || chartColors.blue
48
+ }
49
+
50
+ const { gray: _gray, ...rest } = chartColors
51
+
52
+ export function scheme<T extends { key: string; color?: ChartColor }>(
53
+ data: T[],
54
+ colors: ChartColor[] = Object.keys(rest),
55
+ unknown: ChartColor = 'gray'
56
+ ): (d: T) => string {
57
+ const map = new Map<string, string>(
58
+ data.map((d, i) => [d.key, getColor(d.color, colors?.[i % (colors?.length ?? 1)])])
59
+ )
60
+
61
+ return (d: T): string => map.get(d.key) ?? unknown
62
+ }
63
+
64
+ export async function exportAsPng(_el: any, fileName = 'chart.png', delay = 0): Promise<void> {
65
+ if (!_el) { return }
66
+
67
+ // TODO: automatically wait for chart to be ready
68
+ await new Promise((resolve) => setTimeout(resolve, delay))
69
+
70
+ const el = '$el' in _el ? _el.$el : _el
71
+ if (!(el instanceof HTMLElement)) { return }
72
+
73
+ const SCard = el.closest('.SCard')
74
+ if (!(SCard instanceof HTMLElement)) { return }
75
+
76
+ const canvas = await html2canvas(SCard, {
77
+ scale: 2,
78
+ logging: false,
79
+ ignoreElements: (el) => el.classList.contains('SControlActionBar'),
80
+ onclone(document, element) {
81
+ document.documentElement.classList.remove('dark')
82
+ element.querySelectorAll<HTMLElement>('*').forEach((el) => {
83
+ el.style.backgroundColor = 'transparent'
84
+ el.style.fill = el.getAttribute('fill') ?? el.style.fill
85
+ el.style.stroke = el.getAttribute('stroke') ?? el.style.stroke
86
+ })
87
+ }
88
+ })
89
+
90
+ const dataUrl = canvas.toDataURL('image/png')
91
+ FileSaver.saveAs(dataUrl, fileName)
92
+ }
@@ -16,7 +16,7 @@ export function isDate(value: unknown): value is Date {
16
16
  return _isDate(value)
17
17
  }
18
18
 
19
- export function isError(value: unknown): value is Error {
19
+ export function isError<T extends Error = Error>(value: unknown): value is T {
20
20
  return _isError(value)
21
21
  }
22
22
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
3
  "type": "module",
4
- "version": "4.12.0",
5
- "packageManager": "pnpm@9.15.3",
4
+ "version": "4.14.0",
5
+ "packageManager": "pnpm@9.15.4",
6
6
  "description": "Vue Components for Global Brain Design System.",
7
7
  "author": "Kia Ishii <ka.ishii@globalbrains.com>",
8
8
  "license": "MIT",
@@ -51,32 +51,36 @@
51
51
  "@vue/reactivity": "^3.5.13",
52
52
  "@vuelidate/core": "^2.0.3",
53
53
  "@vuelidate/validators": "^2.0.4",
54
- "@vueuse/core": "^12.4.0",
54
+ "@vueuse/core": "^12 || ^13",
55
55
  "body-scroll-lock": "4.0.0-beta.0",
56
56
  "dayjs": "^1.11.13",
57
- "fuse.js": "^7.0.0",
57
+ "fuse.js": "^7.1.0",
58
58
  "lodash-es": "^4.17.21",
59
59
  "markdown-it": "^14.1.0",
60
60
  "normalize.css": "^8.0.1",
61
- "pinia": "^2.3.0",
62
- "postcss": "^8.4.49",
61
+ "pinia": "^3.0.1",
62
+ "postcss": "^8.5.3",
63
63
  "postcss-nested": "^7.0.2",
64
64
  "v-calendar": "3.0.1",
65
65
  "vue": "^3.5.13",
66
66
  "vue-router": "^4.5.0"
67
67
  },
68
68
  "dependencies": {
69
- "@sentry/browser": "^8.48.0",
69
+ "@sentry/browser": "^9.5.0",
70
+ "@sentry/vue": "^9.5.0",
70
71
  "@tanstack/vue-virtual": "3.0.0-beta.62",
71
72
  "@tinyhttp/content-disposition": "^2.2.2",
72
73
  "@tinyhttp/cookie": "^2.1.1",
74
+ "@types/d3": "^7.4.3",
73
75
  "@types/file-saver": "^2.0.7",
74
- "@types/qs": "^6.9.17",
76
+ "@types/qs": "^6.9.18",
77
+ "d3": "^7.9.0",
75
78
  "file-saver": "^2.0.5",
79
+ "html2canvas": "^1.4.1",
76
80
  "magic-string": "^0.30.17",
77
81
  "ofetch": "^1.4.1",
78
- "qs": "^6.13.1",
79
- "unplugin-icons": "^22.0.0"
82
+ "qs": "^6.14.0",
83
+ "unplugin-icons": "^22.1.0"
80
84
  },
81
85
  "devDependencies": {
82
86
  "@globalbrain/eslint-config": "^1.7.1",
@@ -87,35 +91,35 @@
87
91
  "@types/body-scroll-lock": "^3.1.2",
88
92
  "@types/lodash-es": "^4.17.12",
89
93
  "@types/markdown-it": "^14.1.2",
90
- "@types/node": "^22.10.5",
94
+ "@types/node": "^22.13.10",
91
95
  "@vitejs/plugin-vue": "^5.2.1",
92
- "@vitest/coverage-v8": ">=3.0.0-beta.0",
96
+ "@vitest/coverage-v8": "^3.0.8",
93
97
  "@vue/reactivity": "^3.5.13",
94
98
  "@vue/test-utils": "^2.4.6",
95
99
  "@vuelidate/core": "^2.0.3",
96
100
  "@vuelidate/validators": "^2.0.4",
97
- "@vueuse/core": "^12.4.0",
101
+ "@vueuse/core": "^13.0.0",
98
102
  "body-scroll-lock": "4.0.0-beta.0",
99
103
  "dayjs": "^1.11.13",
100
104
  "eslint": "8.57.0",
101
- "fuse.js": "^7.0.0",
102
- "happy-dom": "^14.12.3",
105
+ "fuse.js": "^7.1.0",
106
+ "happy-dom": "^17.4.4",
103
107
  "histoire": "0.16.5",
104
108
  "lodash-es": "^4.17.21",
105
109
  "markdown-it": "^14.1.0",
106
110
  "normalize.css": "^8.0.1",
107
- "pinia": "^2.3.0",
108
- "postcss": "^8.4.49",
111
+ "pinia": "^3.0.1",
112
+ "postcss": "^8.5.3",
109
113
  "postcss-nested": "^7.0.2",
110
114
  "punycode": "^2.3.1",
111
- "release-it": "^18.1.1",
112
- "typescript": "~5.6.3",
115
+ "release-it": "^18.1.2",
116
+ "typescript": "~5.8.2",
113
117
  "v-calendar": "3.0.1",
114
- "vite": "^6.0.7",
115
- "vitepress": "^1.5.0",
116
- "vitest": ">=3.0.0-beta.0",
118
+ "vite": "^6.2.1",
119
+ "vitepress": ">=2.0.0-alpha.3",
120
+ "vitest": "^3.0.8",
117
121
  "vue": "^3.5.13",
118
122
  "vue-router": "^4.5.0",
119
- "vue-tsc": "^2.2.0"
123
+ "vue-tsc": "^2.2.8"
120
124
  }
121
125
  }