import isObject from 'lodash/isObject' import isString from 'lodash/isString' import max from 'lodash/max' import { ComponentPublicInstance, toRef, ref, onMounted, nextTick, watch } from 'vue' import { isUrl } from '@/utils/strings' import type { Ref, ComputedRef } from 'vue' import { useResizeObserver } from '@/composables/useResizeObserver' import { useChartData } from '@/composables/useChartData' import { useChartFormat } from '@/composables/useChartFormat' import type { ChartData, LoadedData } from '@/composables/useChartData' // Re-exported so the public type surface of this module is unchanged even // though the underlying definitions now live in the data sub-composable. export type { ChartData, LoadedData } interface ChartEmit[] | Record> { emit: { (event: 'resized'): void (event: 'loaded', data: LoadedData): void } } interface ChartPropsDefinition { chartHeightRatio: { type: NumberConstructor } data: { default: () => object[] | string validator(value: string): boolean type: (ArrayConstructor | StringConstructor | ObjectConstructor)[] } dataUrlType: { default: string validator(value: string): boolean type: StringConstructor } socialMode: { type: BooleanConstructor } socialModeRatio: { default: number, type: NumberConstructor } } export interface ChartPropsRefs { chartHeightRatio: Ref data: Ref dataUrlType: Ref<'json' | 'csv' | 'tsv'> socialMode: Ref socialModeRatio: Ref } /** * Wraps a chart component's raw props into individual refs consumable by {@link useChart}. * * @param props - The chart component props (typically from `defineProps`). * @returns A {@link ChartPropsRefs} object exposing each chart prop as its own ref. * @remarks Internal building block consumed by {@link useChart}; not exported from the package root. * @example * // Inside a chart component, alongside chartProps() and useChart(): * const props = defineProps(chartProps()) * const chart = useChart(resizableRef, getChartProps(props), { emit }, isLoaded) */ export function getChartProps(props: { chartHeightRatio?: number data?: ChartData dataUrlType?: 'json' | 'csv' | 'tsv' socialMode?: boolean socialModeRatio?: number }): ChartPropsRefs { return { chartHeightRatio: toRef(props, 'chartHeightRatio'), // toRef() infers `| undefined` from each optional prop; withDefaults() at the // call site guarantees a value, so these casts drop the undefined it can't see. data: toRef(props, 'data') as Ref, dataUrlType: toRef(props, 'dataUrlType') as Ref<'json' | 'csv' | 'tsv'>, socialMode: toRef(props, 'socialMode') as Ref, socialModeRatio: toRef(props, 'socialModeRatio') as Ref } } /** * Builds the shared Vue prop definitions every chart component accepts. * * @returns A {@link ChartPropsDefinition} object passable to `defineProps`. * @remarks Internal building block used by chart components together with {@link useChart}; not exported from the package root. * @example * // Inside a chart component's * * */ export function useChart[] | Record>( resizableRef: Ref | null>, props: ChartPropsRefs, { emit }: ChartEmit, isLoaded: Ref, onResized?: () => void, afterLoaded?: () => Promise ): UseChartReturn { const { resizeRef, resizeState } = useResizeObserver(resizableRef) const mounted = ref(false) onMounted(() => { return nextTick(() => { mounted.value = true }) }) // Data loading: delegate fetching/parsing to the data sub-composable and run // the lifecycle side effects in the order consumers depend on once a load // settles (after the optional afterLoaded hook, flip isLoaded, emit loaded, // then run the initial resize). const { loadedData } = useChartData( { data: props.data, dataUrlType: props.dataUrlType }, async (data) => { await afterLoaded?.() isLoaded.value = true emit('loaded', data) if (onResized) { onResized() emit('resized') } } ) // Formatting and derived config (value formatter, base height ratio, highlight // detection) live in the format sub-composable; they hold no DOM state. const { dataHasHighlights, baseHeightRatio, d3Formatter } = useChartFormat({ loadedData, rawData: toRef(props.data), chartHeightRatio: props.chartHeightRatio, socialMode: props.socialMode, socialModeRatio: props.socialModeRatio }) function elementsMaxBBox({ selector = 'text', defaultWidth = null, defaultHeight = null }: ElementsMaxBBoxOptions = {}): ElementsMaxBBox { const returnDefault = { width: defaultWidth, height: defaultHeight } if (!isLoaded.value || !resizeRef.value) { return returnDefault } const elements: NodeListOf = resizeRef.value.querySelectorAll(selector) if (elements.length == 0) { return returnDefault } const width = max( [...elements].map((element) => { return element.getBBox ? element.getBBox().width : defaultWidth }) ) const height = max( [...elements].map((element) => { return element.getBBox ? element.getBBox().height : defaultHeight }) ) return { width, height } } function xAxisYearFormat(year: number | string) { // previously using narrowWidth, but it is automatically updated through resizeObserver state reactivity return resizeState.narrowWidth ? '’' + String(year).slice(2, 4) : year } watch(resizeState.dimensions, () => { if (isLoaded.value && onResized) { onResized() emit('resized') } }) return { loadedData, mounted, elementsMaxBBox, xAxisYearFormat, d3Formatter, baseHeightRatio, dataHasHighlights } }