@icij/murmur-next 4.7.4 → 4.8.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.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -97,8 +97,12 @@ const color = computed(() => {
97
97
  return colorVariant
98
98
  })
99
99
 
100
+ const isPercentSize = computed(() => {
101
+ return typeof props.size === 'string' && props.size.endsWith('%')
102
+ })
103
+
100
104
  const isRawSize = computed(() => {
101
- return !['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', undefined].includes(props.size)
105
+ return !['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', undefined].includes(props.size) && !isPercentSize.value
102
106
  })
103
107
 
104
108
  const hasSize = computed(() => {
@@ -109,6 +113,7 @@ const style = computed(() => {
109
113
  return {
110
114
  '--app-icon-color': color.value,
111
115
  '--app-icon-raw-size': isRawSize.value ? props.size : undefined,
116
+ '--app-icon-percent-size': isPercentSize.value ? props.size : undefined,
112
117
  '--app-icon-size': hasSize.value ? props.size : undefined,
113
118
  '--app-icon-scale': props.scale ?? 1,
114
119
  '--app-icon-spin-duration': props.spinDuration,
@@ -122,6 +127,7 @@ const classList = computed(() => {
122
127
  [`app-icon--size-${props.size}`]: hasSize.value,
123
128
  [`app-icon--has-size`]: hasSize.value,
124
129
  [`app-icon--raw-size`]: isRawSize.value,
130
+ [`app-icon--percent-size`]: isPercentSize.value,
125
131
  [`app-icon--hover`]: currentHover.value,
126
132
  [`app-icon--spin`]: props.spin,
127
133
  [`app-icon--spin-reverse`]: props.spinReverse,
@@ -171,6 +177,16 @@ const classList = computed(() => {
171
177
  font-size: var(--app-icon-raw-size);
172
178
  }
173
179
 
180
+ &--percent-size {
181
+ width: var(--app-icon-percent-size);
182
+ height: auto;
183
+
184
+ :deep(svg) {
185
+ width: 100%;
186
+ height: auto;
187
+ }
188
+ }
189
+
174
190
  &--spin :deep(svg) {
175
191
  animation: app-icon-spin var(--app-icon-spin-duration, 1s) linear infinite;
176
192
  }
File without changes
@@ -16,6 +16,7 @@ interface ColumnBar {
16
16
  height: number
17
17
  x: number
18
18
  y: number
19
+ isTotal?: boolean
19
20
  }
20
21
 
21
22
  export interface ColumnChartProps {
@@ -131,6 +132,23 @@ export interface ColumnChartProps {
131
132
  * Aspect ratio to use in social mode.
132
133
  */
133
134
  socialModeRatio?: number
135
+ /**
136
+ * Display columns as a waterfall chart where each bar starts where the previous one ended.
137
+ */
138
+ waterfall?: boolean
139
+ /**
140
+ * Show a total bar at the end of the waterfall chart displaying the sum of all values.
141
+ * Only applies when `waterfall` is true.
142
+ */
143
+ waterfallTotal?: boolean
144
+ /**
145
+ * Label for the waterfall total bar on the x-axis.
146
+ */
147
+ waterfallTotalLabel?: string
148
+ /**
149
+ * Color for the waterfall total bar. Falls back to currentColor.
150
+ */
151
+ waterfallTotalColor?: string | null
134
152
  }
135
153
 
136
154
  const props = withDefaults(defineProps<ColumnChartProps>(), {
@@ -161,7 +179,11 @@ const props = withDefaults(defineProps<ColumnChartProps>(), {
161
179
  dataUrlType: 'json',
162
180
  chartHeightRatio: undefined,
163
181
  socialMode: false,
164
- socialModeRatio: 5 / 4
182
+ socialModeRatio: 5 / 4,
183
+ waterfall: false,
184
+ waterfallTotal: false,
185
+ waterfallTotalLabel: 'Total',
186
+ waterfallTotalColor: null
165
187
  })
166
188
 
167
189
  const emit = defineEmits<{
@@ -245,16 +267,29 @@ const padded = computed((): { width: number, height: number } => {
245
267
  })
246
268
 
247
269
  const scaleX = computed((): d3.ScaleBand<string> => {
270
+ const domain = sortedData.value.map(iteratee(props.timeseriesKey))
271
+ if (props.waterfall && props.waterfallTotal) {
272
+ domain.push(props.waterfallTotalLabel)
273
+ }
248
274
  return d3
249
275
  .scaleBand()
250
- .domain(sortedData.value.map(iteratee(props.timeseriesKey)))
276
+ .domain(domain)
251
277
  .range([0, padded.value.width])
252
278
  .padding(props.barPadding)
253
279
  })
254
280
 
281
+ const waterfallTotalValue = computed((): number => {
282
+ return d3.sum(sortedData.value, iteratee(props.seriesName)) ?? 0
283
+ })
284
+
255
285
  const scaleY = computed((): d3.ScaleLinear<number, number> => {
256
- const maxValue
257
- = props.maxValue ?? d3.max(sortedData.value, iteratee(props.seriesName))
286
+ let maxValue: number
287
+ if (props.waterfall) {
288
+ maxValue = props.maxValue ?? waterfallTotalValue.value
289
+ }
290
+ else {
291
+ maxValue = props.maxValue ?? d3.max(sortedData.value, iteratee(props.seriesName))
292
+ }
258
293
  return d3
259
294
  .scaleLinear()
260
295
  .domain([0, maxValue])
@@ -262,13 +297,44 @@ const scaleY = computed((): d3.ScaleLinear<number, number> => {
262
297
  })
263
298
 
264
299
  const bars = computed((): ColumnBar[] => {
300
+ const barWidth = Math.max(1, Math.abs(scaleX.value.bandwidth()) - props.barMargin)
301
+
302
+ if (props.waterfall) {
303
+ let cumulative = 0
304
+ const waterfallBars: ColumnBar[] = sortedData.value.map((datum: any) => {
305
+ const value = datum[props.seriesName]
306
+ cumulative += value
307
+ return {
308
+ datum,
309
+ width: barWidth,
310
+ height: Math.abs(padded.value.height - scaleY.value(value)),
311
+ x: (scaleX.value(datum[props.timeseriesKey]) ?? 0) + props.barMargin / 2,
312
+ y: scaleY.value(cumulative)
313
+ }
314
+ })
315
+
316
+ if (props.waterfallTotal) {
317
+ const totalDatum = {
318
+ [props.timeseriesKey]: props.waterfallTotalLabel,
319
+ [props.seriesName]: waterfallTotalValue.value
320
+ }
321
+ waterfallBars.push({
322
+ datum: totalDatum,
323
+ width: barWidth,
324
+ height: Math.abs(padded.value.height - scaleY.value(waterfallTotalValue.value)),
325
+ x: (scaleX.value(props.waterfallTotalLabel) ?? 0) + props.barMargin / 2,
326
+ y: scaleY.value(waterfallTotalValue.value),
327
+ isTotal: true
328
+ })
329
+ }
330
+
331
+ return waterfallBars
332
+ }
333
+
265
334
  return sortedData.value.map((datum: any) => {
266
335
  return {
267
336
  datum,
268
- width: Math.max(
269
- 1,
270
- Math.abs(scaleX.value.bandwidth()) - props.barMargin
271
- ),
337
+ width: barWidth,
272
338
  height: Math.abs(
273
339
  padded.value.height - scaleY.value(datum[props.seriesName])
274
340
  ),
@@ -298,9 +364,14 @@ const xAxisTickValues = computed((): string[] => {
298
364
  const ticks
299
365
  = props.xAxisTicks ?? sortedData.value.map(iteratee(props.timeseriesKey))
300
366
  // Then filter out ticks according to `this.xAxisHiddenTicks`
301
- return ticks.map((tick: string, i: number) => {
367
+ const filtered = ticks.map((tick: string, i: number) => {
302
368
  return (i + 1) % xAxisHiddenTicks.value ? null : tick
303
369
  }) as string[]
370
+ // Add the total label for waterfall charts
371
+ if (props.waterfall && props.waterfallTotal) {
372
+ filtered.push(props.waterfallTotalLabel)
373
+ }
374
+ return filtered
304
375
  })
305
376
 
306
377
  const xAxis = computed((): d3.Axis<string> => {
@@ -383,11 +454,13 @@ watch(() => props.socialMode, update, { immediate: true })
383
454
  'column-chart--hover': hover,
384
455
  'column-chart--stripped': stripped,
385
456
  'column-chart--social-mode': socialMode,
386
- 'column-chart--loaded': isLoaded
457
+ 'column-chart--loaded': isLoaded,
458
+ 'column-chart--waterfall': waterfall
387
459
  }"
388
460
  :style="{
389
461
  '--column-color': columnColor,
390
- '--column-highlight-color': columnHighlightColor
462
+ '--column-highlight-color': columnHighlightColor,
463
+ '--column-total-color': waterfallTotalColor
391
464
  }"
392
465
  class="column-chart"
393
466
  >
@@ -414,7 +487,8 @@ watch(() => props.socialMode, update, { immediate: true })
414
487
  v-for="(bar, index) in bars"
415
488
  :key="index"
416
489
  :class="{
417
- 'column-chart__columns__item--highlight': highlighted(bar.datum)
490
+ 'column-chart__columns__item--highlight': highlighted(bar.datum),
491
+ 'column-chart__columns__item--total': bar.isTotal
418
492
  }"
419
493
  :style="{ transform: `translate(${bar.x}px, 0px)` }"
420
494
  class="column-chart__columns__item"
@@ -500,6 +574,10 @@ watch(() => props.socialMode, update, { immediate: true })
500
574
  fill: var(--column-highlight-color, var(--primary, $primary));
501
575
  }
502
576
 
577
+ &--total {
578
+ fill: var(--column-total-color, currentColor);
579
+ }
580
+
503
581
  &__placeholder {
504
582
  opacity: 0;
505
583
 
@@ -1,15 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import * as d3 from 'd3'
3
- import find from 'lodash/find'
4
3
  import get from 'lodash/get'
5
4
  import identity from 'lodash/identity'
6
5
  import kebabCase from 'lodash/kebabCase'
7
6
  import keysFn from 'lodash/keys'
8
7
  import without from 'lodash/without'
9
8
  import sortByFn from 'lodash/sortBy'
10
- import { ComponentPublicInstance, computed, ref, watch } from 'vue'
9
+ import { ComponentPublicInstance, computed, nextTick, ref, watch } from 'vue'
11
10
  import { getChartProps, useChart } from '@/composables/useChart'
12
- import { useQueryObserver } from '@/composables/useQueryObserver'
13
11
  import { isArray } from 'lodash'
14
12
 
15
13
  defineOptions({
@@ -150,8 +148,6 @@ const {
150
148
  dataHasHighlights
151
149
  } = useChart(el, getChartProps(props), { emit }, isLoaded)
152
150
 
153
- const { querySelectorAll } = useQueryObserver(el.value)
154
-
155
151
  const hasConstraintHeight = computed(() => {
156
152
  return props.fixedHeight !== null || props.socialMode
157
153
  })
@@ -308,12 +304,13 @@ function stackBarAndValue(i: number | string): StackItem[] {
308
304
  }
309
305
 
310
306
  function queryBarAndValue(i: number, key: string) {
311
- if (!mounted.value) {
307
+ const root = el.value as unknown as HTMLElement
308
+ if (!mounted.value || !root) {
312
309
  return {}
313
310
  }
314
311
  const barClass = 'stacked-bar-chart__groups__item__bars__item'
315
312
  const rowSelector = '.stacked-bar-chart__groups__item'
316
- const row = querySelectorAll(rowSelector)[i] as HTMLElement
313
+ const row = root.querySelectorAll(rowSelector)[i] as HTMLElement
317
314
  const normalizedKey = normalizeKey(key)
318
315
  const barSelector = `.${barClass}--${normalizedKey}`
319
316
  const bar = row?.querySelector(barSelector) as HTMLElement
@@ -322,35 +319,59 @@ function queryBarAndValue(i: number, key: string) {
322
319
  return { bar, row, value }
323
320
  }
324
321
 
325
- function hasValueOverflow(i: number | string, key: string) {
326
- try {
327
- const stack = stackBarAndValue(i)
328
- return find(stack, { key })?.overflow
329
- }
330
- catch {
331
- return false
322
+ interface LabelState {
323
+ overflow: boolean
324
+ pushed: boolean
325
+ hidden: boolean
326
+ }
327
+
328
+ const labelStates = ref<Record<string, LabelState>>({})
329
+
330
+ function labelStateKey(i: number | string, key: string) {
331
+ return `${i}-${key}`
332
+ }
333
+
334
+ function computeLabelStates() {
335
+ const root = el.value as unknown as HTMLElement
336
+ if (!root || !mounted.value || !sortedData.value?.length) return
337
+
338
+ const states: Record<string, LabelState> = {}
339
+
340
+ for (let i = 0; i < sortedData.value.length; i++) {
341
+ try {
342
+ const stack = stackBarAndValue(i)
343
+ for (const item of stack) {
344
+ states[labelStateKey(i, item.key)] = {
345
+ overflow: item.overflow,
346
+ pushed: item.pushed,
347
+ hidden: false
348
+ }
349
+ }
350
+ for (let j = 0; j < stack.length; j++) {
351
+ const nextItem = stack[j + 1]
352
+ if (nextItem && stack[j].overflow && nextItem.overflow) {
353
+ states[labelStateKey(i, stack[j].key)].hidden = true
354
+ }
355
+ }
356
+ }
357
+ catch {
358
+ // If measurement fails for a row, skip it
359
+ }
332
360
  }
361
+
362
+ labelStates.value = states
363
+ }
364
+
365
+ function hasValueOverflow(i: number | string, key: string) {
366
+ return labelStates.value[labelStateKey(i, key)]?.overflow ?? false
333
367
  }
334
368
 
335
369
  function hasValuePushed(i: number | string, key: string) {
336
- try {
337
- const stack = stackBarAndValue(i)
338
- return find(stack, { key })?.pushed
339
- }
340
- catch {
341
- return false
342
- }
370
+ return labelStates.value[labelStateKey(i, key)]?.pushed ?? false
343
371
  }
344
372
 
345
373
  function hasValueHidden(i: number | string, key: string) {
346
- const keyIndex = discoveredKeys.value.indexOf(key)
347
- const nextKey = discoveredKeys.value[keyIndex + 1]
348
- if (!nextKey) {
349
- return false
350
- }
351
- const keyC = hasValueOverflow(i, key)
352
- const keyN = hasValueOverflow(i, nextKey)
353
- return keyC && keyN
374
+ return labelStates.value[labelStateKey(i, key)]?.hidden ?? false
354
375
  }
355
376
 
356
377
  function isHidden(i: number | string, key: string) {
@@ -364,6 +385,12 @@ function formatXDatum(d: string) {
364
385
  watch(() => props.highlights, (newHighlights: string[]) => {
365
386
  highlightedKeys.value = newHighlights
366
387
  })
388
+
389
+ // Compute label states after DOM layout
390
+ watch(sortedData, async () => {
391
+ await nextTick()
392
+ computeLabelStates()
393
+ })
367
394
  </script>
368
395
 
369
396
  <template>
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import * as d3 from 'd3'
3
3
  import keysFn from 'lodash/keys'
4
- import find from 'lodash/find'
5
4
  import get from 'lodash/get'
6
5
  import identity from 'lodash/identity'
7
6
  import sortByFn from 'lodash/sortBy'
@@ -15,7 +14,6 @@ import {
15
14
  watch
16
15
  } from 'vue'
17
16
  import { getChartProps, useChart } from '@/composables/useChart'
18
- import { useQueryObserver } from '@/composables/useQueryObserver'
19
17
 
20
18
  defineOptions({
21
19
  name: 'StackedColumnChart'
@@ -157,7 +155,6 @@ const highlightedKeys = ref(props.highlights)
157
155
  const highlightTimeout = ref<ReturnType<typeof setTimeout> | undefined>(undefined)
158
156
  const isLoaded = ref(false)
159
157
  const el = ref<ComponentPublicInstance<HTMLElement> | null>(null)
160
- const { querySelector, querySelectorAll } = useQueryObserver(el.value)
161
158
 
162
159
  const {
163
160
  elementsMaxBBox,
@@ -383,11 +380,12 @@ function stackBarAndValue(i: string | number): StackItem[] {
383
380
  }
384
381
 
385
382
  function queryBarAndValue(i: number, key: string) {
386
- if (!mounted.value) {
383
+ const root = el.value as unknown as HTMLElement
384
+ if (!mounted.value || !root) {
387
385
  return {}
388
386
  }
389
387
  const rowSelector = '.stacked-column-chart__groups__item'
390
- const row = querySelectorAll(rowSelector)[i] as HTMLElement
388
+ const row = root.querySelectorAll(rowSelector)[i] as HTMLElement
391
389
  const barSelector = `.stacked-column-chart__groups__item__bars__item--${key}`
392
390
  const bar = row.querySelector(barSelector) as HTMLElement
393
391
  const valueSelector = '.stacked-column-chart__groups__item__bars__item__value'
@@ -399,33 +397,60 @@ function isHidden(i: string | number, key: string) {
399
397
  return props.hideEmptyValues && !sortedData.value[i as number][key]
400
398
  }
401
399
 
402
- function hasValueOverflow(i: string | number, key: string) {
403
- try {
404
- const stack = stackBarAndValue(i)
405
- return find(stack, { key })?.overflow
406
- }
407
- catch {
408
- return false
400
+ interface LabelState {
401
+ overflow: boolean
402
+ pushed: boolean
403
+ hidden: boolean
404
+ }
405
+
406
+ const labelStates = ref<Record<string, LabelState>>({})
407
+
408
+ function labelStateKey(i: number | string, key: string) {
409
+ return `${i}-${key}`
410
+ }
411
+
412
+ function computeLabelStates() {
413
+ const root = el.value as unknown as HTMLElement
414
+ if (!root || !mounted.value || !sortedData.value?.length) return
415
+
416
+ const states: Record<string, LabelState> = {}
417
+
418
+ for (let i = 0; i < sortedData.value.length; i++) {
419
+ try {
420
+ const stack = stackBarAndValue(i)
421
+ for (const item of stack) {
422
+ states[labelStateKey(i, item.key)] = {
423
+ overflow: item.overflow,
424
+ pushed: item.pushed,
425
+ hidden: false
426
+ }
427
+ }
428
+ // A value is hidden when both it and the next key overflow
429
+ for (let j = 0; j < stack.length; j++) {
430
+ const nextItem = stack[j + 1]
431
+ if (nextItem && stack[j].overflow && nextItem.overflow) {
432
+ states[labelStateKey(i, stack[j].key)].hidden = true
433
+ }
434
+ }
435
+ }
436
+ catch {
437
+ // If measurement fails for a row, skip it
438
+ }
409
439
  }
440
+
441
+ labelStates.value = states
442
+ }
443
+
444
+ function hasValueOverflow(i: string | number, key: string) {
445
+ return labelStates.value[labelStateKey(i, key)]?.overflow ?? false
410
446
  }
411
447
 
412
448
  function hasValuePushed(i: string | number, key: string) {
413
- try {
414
- const stack = stackBarAndValue(i)
415
- return find(stack, { key })?.pushed
416
- }
417
- catch {
418
- return false
419
- }
449
+ return labelStates.value[labelStateKey(i, key)]?.pushed ?? false
420
450
  }
421
451
 
422
452
  function hasValueHidden(i: string | number, key: string) {
423
- const keyIndex = discoveredKeys.value.indexOf(key)
424
- const nextKey = discoveredKeys.value[keyIndex + 1]
425
- if (!nextKey) {
426
- return false
427
- }
428
- return hasValueOverflow(i, key) && hasValueOverflow(i, nextKey)
453
+ return labelStates.value[labelStateKey(i, key)]?.hidden ?? false
429
454
  }
430
455
 
431
456
  function formatXDatum(d: string) {
@@ -445,13 +470,17 @@ watch(
445
470
 
446
471
  watch(sortedData, async () => {
447
472
  await nextTick()
473
+ const root = el.value as unknown as HTMLElement
474
+ if (!root) return
448
475
  // This must be set after the column have been rendered
449
- const element = querySelector('.stacked-column-chart__groups__item__bars')
476
+ const element = root.querySelector('.stacked-column-chart__groups__item__bars') as HTMLElement
450
477
  // Update the left axis only if the bars exists
451
478
  if (element) {
452
- leftAxisHeight.value = (element as HTMLElement).offsetHeight
479
+ leftAxisHeight.value = element.offsetHeight
453
480
  leftAxisCanvas.value.call(leftAxis.value as any)
454
481
  }
482
+ // Compute label overflow/pushed/hidden states after DOM layout
483
+ computeLabelStates()
455
484
  })
456
485
  </script>
457
486
 
@@ -484,8 +513,8 @@ watch(sortedData, async () => {
484
513
  <span
485
514
  class="stacked-column-chart__legend__item__box"
486
515
  :style="{ 'background-color': colorScale(key) }"
487
- >
488
- {{ groupName(key) }}</span>
516
+ />
517
+ <span class="stacked-column-chart__legend__item__label">{{ groupName(key) }}</span>
489
518
  </li>
490
519
  </ul>
491
520
  <div class="d-flex flex-grow-1 position-relative overflow-hidden">
package/lib/keys.ts CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icij/murmur-next",
3
- "version": "4.7.4",
3
+ "version": "4.8.0",
4
4
  "private": false,
5
5
  "description": "Murmur is ICIJ's Design System for Bootstrap 5 and Vue.js",
6
6
  "author": "promera@icij.org",