@datagouv/components-next 1.0.2-dev.52 → 1.0.2-dev.54

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.
Files changed (41) hide show
  1. package/assets/main.css +4 -0
  2. package/dist/{Datafair.client-CBE0uebV.js → Datafair.client-qm_JoZUL.js} +1 -1
  3. package/dist/{JsonPreview.client-B-kw70ep.js → JsonPreview.client-BpovqdDN.js} +2 -2
  4. package/dist/{MapContainer.client-dUUInlHa.js → MapContainer.client-6Y5RJxtw.js} +2 -2
  5. package/dist/{PdfPreview.client-Y2qTpKFd.js → PdfPreview.client-Drv5EwJe.js} +2 -2
  6. package/dist/{Pmtiles.client-BOPJ59Cq.js → Pmtiles.client-B3dUb4iS.js} +1 -1
  7. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-DtgGpKmM.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-BmRAxeK4.js} +1 -1
  8. package/dist/{XmlPreview.client-CtW1m4O8.js → XmlPreview.client-CXF1N-AI.js} +3 -3
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +144 -145
  11. package/dist/components.css +1 -1
  12. package/dist/{index-B8siUkxs.js → index-lCAbcwQm.js} +1 -1
  13. package/dist/{main-DaWGX8hL.js → main-5ZJvZtsQ.js} +31672 -52962
  14. package/dist/{vue3-xml-viewer.common-S9Mg9vGE.js → vue3-xml-viewer.common-X_gxbf2s.js} +1 -1
  15. package/package.json +1 -3
  16. package/src/components/InfiniteLoader.vue +53 -0
  17. package/src/components/ResourceAccordion/Preview.vue +11 -11
  18. package/src/components/ResourceAccordion/ResourceAccordion.vue +1 -1
  19. package/src/components/ResourceExplorer/ResourceExplorer.vue +60 -3
  20. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +2 -2
  21. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +4 -3
  22. package/src/components/Search/GlobalSearch.vue +45 -4
  23. package/src/components/TabularExplorer/TabularCell.vue +51 -0
  24. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  25. package/src/components/TabularExplorer/TabularExplorer.vue +870 -0
  26. package/src/components/TabularExplorer/TabularFilterContent.vue +351 -0
  27. package/src/components/TabularExplorer/TabularFilterPopover.vue +111 -0
  28. package/src/components/TabularExplorer/types.ts +83 -0
  29. package/src/composables/useHasTabularData.ts +1 -9
  30. package/src/composables/useSearchFilter.ts +90 -0
  31. package/src/composables/useStableQueryParams.ts +28 -3
  32. package/src/functions/api.ts +34 -33
  33. package/src/functions/api.types.ts +1 -0
  34. package/src/functions/tabular.ts +60 -0
  35. package/src/functions/tabularApi.ts +9 -137
  36. package/src/main.ts +9 -27
  37. package/src/components/Chart/ChartViewer.vue +0 -147
  38. package/src/components/Chart/ChartViewerWrapper.vue +0 -224
  39. package/src/components/Form/Listbox.vue +0 -101
  40. package/src/functions/charts.ts +0 -68
  41. package/src/types/visualizations.ts +0 -89
@@ -1,11 +1,13 @@
1
- import { ref, watch, type Ref } from 'vue'
1
+ import { computed, ref, watch, type Ref } from 'vue'
2
2
  import type { SearchTypeConfig } from '../types/search'
3
+ import { forEachActiveCustomFilter, type CustomFilterEntry } from './useSearchFilter'
3
4
 
4
5
  type FilterRefs = Record<string, Ref<unknown>>
5
6
 
6
7
  interface StableQueryParamsOptions {
7
8
  typeConfig: SearchTypeConfig | undefined
8
9
  allFilters: FilterRefs
10
+ customFilterRegistry: Map<string, CustomFilterEntry>
9
11
  q: Ref<string>
10
12
  sort: Ref<string | undefined>
11
13
  page: Ref<number>
@@ -17,7 +19,7 @@ interface StableQueryParamsOptions {
17
19
  * Applies hiddenFilters first, then user filters (which can override hiddenFilters).
18
20
  */
19
21
  export function useStableQueryParams(options: StableQueryParamsOptions) {
20
- const { typeConfig, allFilters, q, sort, page, pageSize } = options
22
+ const { typeConfig, allFilters, customFilterRegistry, q, sort, page, pageSize } = options
21
23
  const stableParams = ref<Record<string, unknown>>({})
22
24
 
23
25
  const buildParams = () => {
@@ -50,6 +52,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
50
52
  }
51
53
  }
52
54
 
55
+ // 3.5. Apply custom filter values. Concatenate into an array on collision
56
+ // so a custom filter mapped onto a built-in apiParam (e.g. theme → tag)
57
+ // combines with an existing built-in value instead of overwriting it.
58
+ forEachActiveCustomFilter(customFilterRegistry, (apiParam, value) => {
59
+ const existing = params[apiParam]
60
+ if (existing === undefined) {
61
+ params[apiParam] = value
62
+ }
63
+ else {
64
+ params[apiParam] = Array.isArray(existing) ? [...existing, value] : [existing, value]
65
+ }
66
+ })
67
+
53
68
  // 4. Always include q, sort (if valid for this type), page, page_size
54
69
  if (q.value) {
55
70
  params.q = q.value
@@ -66,9 +81,19 @@ export function useStableQueryParams(options: StableQueryParamsOptions) {
66
81
  return params
67
82
  }
68
83
 
84
+ // Computed that reads all custom filter values, establishing reactive dependencies
85
+ // on both the Map mutations (shallowReactive) and each entry's ref.value.
86
+ const customFilterValues = computed(() => {
87
+ const snapshot: Record<string, unknown> = {}
88
+ for (const [urlParam, entry] of customFilterRegistry) {
89
+ snapshot[urlParam] = entry.ref.value
90
+ }
91
+ return snapshot
92
+ })
93
+
69
94
  // Watch all dependencies and update only if content changed
70
95
  watch(
71
- [q, sort, page, ...Object.values(allFilters)],
96
+ [q, sort, page, ...Object.values(allFilters), customFilterValues],
72
97
  () => {
73
98
  const newParams = buildParams()
74
99
  // JSON.stringify comparison is safe here because buildParams() builds the object deterministically
@@ -24,6 +24,7 @@ export async function useFetch<DataT, ErrorT = never>(
24
24
  return await config.customUseFetch(url, options)
25
25
  }
26
26
 
27
+ const isRaw = options?.raw
27
28
  const data: Ref<DataT | null> = ref(null)
28
29
  const error: Ref<ErrorT | null> = ref(null)
29
30
  const status = ref<AsyncDataRequestStatus>('idle')
@@ -35,37 +36,39 @@ export async function useFetch<DataT, ErrorT = never>(
35
36
  const query = deepToValue(options?.query)
36
37
  status.value = 'pending'
37
38
  try {
38
- data.value = await ofetch<DataT | null>(urlValue, {
39
- baseURL: config.apiBase,
40
- params: params ?? query,
41
- onRequest(param) {
42
- if (config.onRequest) {
43
- if (Array.isArray(config.onRequest)) {
44
- config.onRequest.forEach(r => r(param))
45
- }
46
- else {
47
- config.onRequest(param)
48
- }
49
- }
50
- const { options } = param
51
- options.headers.set('Content-Type', 'application/json')
52
- options.headers.set('Accept', 'application/json')
53
- options.credentials = 'include'
54
- if (config.devApiKey) {
55
- options.headers.set('X-API-KEY', config.devApiKey)
56
- }
39
+ data.value = isRaw
40
+ ? await ofetch<DataT | null>(urlValue, { params: params ?? query })
41
+ : await ofetch<DataT | null>(urlValue, {
42
+ baseURL: config.apiBase,
43
+ params: params ?? query,
44
+ onRequest(param) {
45
+ if (config.onRequest) {
46
+ if (Array.isArray(config.onRequest)) {
47
+ config.onRequest.forEach(r => r(param))
48
+ }
49
+ else {
50
+ config.onRequest(param)
51
+ }
52
+ }
53
+ const { options } = param
54
+ options.headers.set('Content-Type', 'application/json')
55
+ options.headers.set('Accept', 'application/json')
56
+ options.credentials = 'include'
57
+ if (config.devApiKey) {
58
+ options.headers.set('X-API-KEY', config.devApiKey)
59
+ }
57
60
 
58
- if (locale) {
59
- if (!options.params) {
60
- options.params = {}
61
- }
62
- options.params['lang'] = locale
63
- }
64
- },
65
- onRequestError: config.onRequestError,
66
- onResponse: config.onResponse,
67
- onResponseError: config.onResponseError,
68
- })
61
+ if (locale) {
62
+ if (!options.params) {
63
+ options.params = {}
64
+ }
65
+ options.params['lang'] = locale
66
+ }
67
+ },
68
+ onRequestError: config.onRequestError,
69
+ onResponse: config.onResponse,
70
+ onResponseError: config.onResponseError,
71
+ })
69
72
  status.value = 'success'
70
73
  }
71
74
  catch (e) {
@@ -90,9 +93,7 @@ export async function useFetch<DataT, ErrorT = never>(
90
93
 
91
94
  return {
92
95
  data,
93
- refresh: async () => {
94
- execute()
95
- },
96
+ refresh: () => execute(),
96
97
  execute,
97
98
  clear: () => {
98
99
  data.value = null
@@ -20,6 +20,7 @@ export type UseFetchOptions<DataT> = {
20
20
  transform?: (input: DataT) => DataT | Promise<DataT>
21
21
  pick?: string[]
22
22
  watch?: WatchSource[] | false
23
+ raw?: boolean
23
24
  }
24
25
 
25
26
  export type AsyncData<DataT, ErrorT> = {
@@ -0,0 +1,60 @@
1
+ import type { Component } from 'vue'
2
+ import {
3
+ RiHashtag,
4
+ RiPriceTag3Line,
5
+ RiText,
6
+ RiCalendarLine,
7
+ RiCheckboxLine,
8
+ } from '@remixicon/vue'
9
+ import { useTranslation } from '../composables/useTranslation'
10
+ import type { ColumnFilters, ColumnType } from '../components/TabularExplorer/types'
11
+
12
+ export function hasFilterForColumn(filters: Record<string, ColumnFilters>, column: string): boolean {
13
+ const f = filters[column]
14
+ if (!f) return false
15
+ return !!(f.in?.length || f.exact != null || f.contains || f.null || f.min != null || f.max != null)
16
+ }
17
+
18
+ export function buildTypeConfig(t: (s: string) => string): Record<ColumnType, { icon: Component, label: string }> {
19
+ return {
20
+ number: { icon: RiHashtag, label: t('Nombre') },
21
+ categorical: { icon: RiPriceTag3Line, label: t('Catégoriel') },
22
+ text: { icon: RiText, label: t('Texte') },
23
+ date: { icon: RiCalendarLine, label: t('Date') },
24
+ boolean: { icon: RiCheckboxLine, label: t('Booléen') },
25
+ }
26
+ }
27
+
28
+ export function useFormatTabular() {
29
+ const { locale } = useTranslation()
30
+
31
+ function formatNumber(value: unknown): string {
32
+ const num = Number(value)
33
+ if (Number.isNaN(num)) return String(value)
34
+ return num.toLocaleString(locale)
35
+ }
36
+
37
+ function formatCellDate(value: unknown): string {
38
+ if (value == null || value === '') return '–'
39
+ const d = new Date(String(value))
40
+ if (Number.isNaN(d.getTime())) return String(value)
41
+ return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d)
42
+ }
43
+
44
+ return { formatNumber, formatCellDate }
45
+ }
46
+
47
+ const TRUTHY_VALUES = ['true', '1', 'oui', 'yes']
48
+ const FALSY_VALUES = ['false', '0', 'non', 'no']
49
+
50
+ export function isTruthy(value: unknown): boolean {
51
+ if (typeof value === 'boolean') return value
52
+ if (typeof value === 'string') return TRUTHY_VALUES.includes(value.toLowerCase())
53
+ return Boolean(value)
54
+ }
55
+
56
+ export function isFalsy(value: unknown): boolean {
57
+ if (typeof value === 'boolean') return !value
58
+ if (typeof value === 'string') return FALSY_VALUES.includes(value.toLowerCase())
59
+ return !value
60
+ }
@@ -1,146 +1,18 @@
1
1
  import { ofetch } from 'ofetch'
2
2
  import { useComponentsConfig, type PluginConfig } from '../config'
3
- import type { GenericFilter } from '../types/visualizations'
3
+ import type { SortConfig } from '../components/TabularExplorer/types'
4
4
 
5
- export type SortConfig = {
6
- column: string
7
- type: string
8
- } | null
9
-
10
- export type TabularDataResponse = {
11
- data: Array<Record<string, unknown>>
12
- links: {
13
- profile: string
14
- swagger: string
15
- next: string
16
- }
17
- meta: { total: number }
18
- }
19
-
20
- export type TabularAggregateType = 'avg' | 'sum' | 'count' | 'min' | 'max'
21
-
22
- export type FetchTabularDataOptions = {
23
- resourceId: string
24
- page?: number
25
- pageSize?: number
26
- columns?: Array<string> | undefined
27
- sort?: SortConfig
28
- groupBy?: string | undefined
29
- aggregation?: {
30
- column: string
31
- type: TabularAggregateType
32
- } | undefined
33
- filters?: GenericFilter | undefined
34
- }
35
-
36
- export type TabularProfileResponse = {
37
- profile: {
38
- header: Array<string>
39
- columns: Record<string, {
40
- score: number
41
- format: string
42
- python_type: string
43
- }>
44
- formats: Record<string, Array<string>>
45
- profile: Record<string, {
46
- tops: Array<{ count: number, value: string }>
47
- nb_distinct: number
48
- nb_missing_values: number
49
- min?: number
50
- max?: number
51
- std?: number
52
- mean?: number
53
- }>
54
- encoding: string
55
- separator: string
56
- categorical: Array<string>
57
- total_lines: number
58
- nb_duplicates: number
59
- columns_fields: Record<string, {
60
- score: number
61
- format: string
62
- python_type: string
63
- }>
64
- columns_labels: Record<string, {
65
- score: number
66
- format: string
67
- python_type: string
68
- }>
69
- header_row_idx: number
70
- heading_columns: number
71
- trailing_columns: number
72
- }
73
- deleted_at: string | null
74
- dataset_id: string
75
- indexes: null
76
- }
77
-
78
- /**
79
- * Call Tabular-api to get table content with options object
80
- */
81
- export async function fetchTabularData(config: PluginConfig, options: FetchTabularDataOptions): Promise<TabularDataResponse> {
82
- const page = options.page ?? 1
83
- const pageSize = options.pageSize ?? config.tabularApiPageSize ?? 15
84
- console.log(options)
85
- let url = `${config.tabularApiUrl}/api/resources/${options.resourceId}/data/?page=${page}&page_size=${pageSize}`
86
- if (options.columns) {
87
- url += `&columns=${options.columns.join(',')}`
88
- }
89
- if (options.sort) {
90
- url += `&${options.sort.column}__sort=${options.sort.type}`
91
- }
92
- if (options.groupBy && options.aggregation?.type) {
93
- url += `&${options.groupBy}__groupby&${options.aggregation.column}__${options.aggregation.type}`
94
- }
95
- if (options.filters) {
96
- const filterQuery = buildFilterQuery(options.filters)
97
- if (filterQuery) {
98
- url += `&${filterQuery}`
99
- }
100
- }
101
- return await ofetch<TabularDataResponse>(url)
102
- }
103
-
104
- function buildFilterQuery(filters: GenericFilter): string {
105
- const params: Array<string> = []
106
- if ('filters' in filters) {
107
- for (const filter of filters.filters) {
108
- if ('filters' in filter) {
109
- params.push(buildFilterQuery(filter))
110
- }
111
- else {
112
- if (filter.condition === 'is_null') {
113
- params.push(`${filter.column}__isnull`)
114
- }
115
- else if (filter.condition === 'is_not_null') {
116
- params.push(`${filter.column}__isnotnull`)
117
- }
118
- else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
119
- params.push(`${filter.column}__${filter.condition}=${encodeURIComponent(filter.value)}`)
120
- }
121
- }
122
- }
123
- }
124
- else {
125
- const filter = filters
126
- if (filter.condition === 'is_null') {
127
- params.push(`${filter.column}__isnull`)
128
- }
129
- else if (filter.condition === 'is_not_null') {
130
- params.push(`${filter.column}__isnotnull`)
131
- }
132
- else if (filter.value !== null && filter.value !== undefined && filter.value !== '') {
133
- params.push(`${filter.column}__${filter.condition}=${encodeURIComponent(filter.value)}`)
134
- }
135
- }
136
- return params.join('&')
137
- }
5
+ export type { SortConfig }
138
6
 
139
7
  /**
140
8
  * Call Tabular-api to get table content
141
9
  */
142
- export function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig) {
143
- return fetchTabularData(config, { resourceId: id, page, sort: sortConfig })
10
+ export async function getData(config: PluginConfig, id: string, page: number, sortConfig?: SortConfig | null) {
11
+ let url = `${config.tabularApiUrl}/api/resources/${id}/data/?page=${page}&page_size=${config.tabularApiPageSize || 15}`
12
+ if (sortConfig) {
13
+ url = url + `&${sortConfig.column}__sort=${sortConfig.direction}`
14
+ }
15
+ return await ofetch(url)
144
16
  }
145
17
 
146
18
  /**
@@ -148,5 +20,5 @@ export function getData(config: PluginConfig, id: string, page: number, sortConf
148
20
  */
149
21
  export function useGetProfile() {
150
22
  const config = useComponentsConfig()
151
- return (id: string) => ofetch<TabularProfileResponse>(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
23
+ return (id: string) => ofetch(`${config.tabularApiUrl}/api/resources/${id}/profile/`)
152
24
  }
package/src/main.ts CHANGED
@@ -23,9 +23,10 @@ import type { Site } from './types/site'
23
23
  import type { Weight, WellType } from './types/ui'
24
24
  import type { User, UserReference } from './types/users'
25
25
  import type { Report, ReportSubject, ReportReason } from './types/reports'
26
- import type { Chart, ChartForm, ChartForApi, FilterCondition, Filter, AndFilters, GenericFilter, XAxisType, XAxisSortBy, SortDirection, XAxis, XAxisForm, UnitPosition, YAxis, DataSeriesType, DataSeries, DataSeriesForm } from './types/visualizations'
27
26
  import type { GlobalSearchConfig, SearchType, SortOption } from './types/search'
28
27
  import { getDefaultDatasetConfig, getDefaultDataserviceConfig, getDefaultReuseConfig, getDefaultOrganizationConfig, getDefaultTopicConfig, getDefaultGlobalSearchConfig, defaultDatasetSortOptions, defaultDataserviceSortOptions, defaultReuseSortOptions, defaultOrganizationSortOptions } from './types/search'
28
+ import { useSearchFilter } from './composables/useSearchFilter'
29
+ import type { UseSearchFilterOptions } from './composables/useSearchFilter'
29
30
 
30
31
  import ActivityList from './components/ActivityList/ActivityList.vue'
31
32
  import UserActivityList from './components/ActivityList/UserActivityList.vue'
@@ -81,8 +82,6 @@ import ReuseDetails from './components/ReuseDetails.vue'
81
82
  import SchemaCard from './components/SchemaCard.vue'
82
83
  import SimpleBanner from './components/SimpleBanner.vue'
83
84
  import SmallChart from './components/SmallChart.vue'
84
- import ChartViewer from './components/Chart/ChartViewer.vue'
85
- import ChartViewerWrapper from './components/Chart/ChartViewerWrapper.vue'
86
85
  import StatBox from './components/StatBox.vue'
87
86
  import Tab from './components/Tabs/Tab.vue'
88
87
  import TabGroup from './components/Tabs/TabGroup.vue'
@@ -97,14 +96,14 @@ import GlobalSearch from './components/Search/GlobalSearch.vue'
97
96
  import SearchInput from './components/Search/SearchInput.vue'
98
97
  import SearchableSelect from './components/Form/SearchableSelect.vue'
99
98
  import SelectGroup from './components/Form/SelectGroup.vue'
100
- import Listbox from './components/Form/Listbox.vue'
99
+ import InfiniteLoader from './components/InfiniteLoader.vue'
100
+ import TabularExplorer from './components/TabularExplorer/TabularExplorer.vue'
101
101
  import type { UseFetchFunction } from './functions/api.types'
102
102
  import { configKey, useComponentsConfig, type PluginConfig } from './config.js'
103
103
 
104
104
  export { Toaster, toast } from 'vue-sonner'
105
105
 
106
106
  export * from './composables/useActiveDescendant'
107
- export * from './composables/useDebouncedRef'
108
107
  export * from './composables/useMetrics'
109
108
  export * from './composables/useReuseType'
110
109
  export * from './composables/useTranslation'
@@ -124,15 +123,15 @@ export * from './functions/owned'
124
123
  export * from './functions/resources'
125
124
  export * from './functions/reuses'
126
125
  export * from './functions/schemas'
126
+ export * from './functions/tabular'
127
127
  export * from './functions/users'
128
- export * from './functions/tabularApi'
129
- export * from './functions/charts'
130
128
  export * from './types/access_types'
131
129
 
132
130
  export type {
133
131
  GlobalSearchConfig,
134
132
  SearchType,
135
133
  SortOption,
134
+ UseSearchFilterOptions,
136
135
  UseFetchFunction,
137
136
  AccessType,
138
137
  AccessAudience,
@@ -223,23 +222,6 @@ export type {
223
222
  ValidataError,
224
223
  Weight,
225
224
  WellType,
226
- Chart,
227
- ChartForm,
228
- ChartForApi,
229
- FilterCondition,
230
- Filter,
231
- AndFilters,
232
- GenericFilter,
233
- XAxisType,
234
- XAxisSortBy,
235
- SortDirection,
236
- XAxis,
237
- XAxisForm,
238
- UnitPosition,
239
- YAxis,
240
- DataSeriesType,
241
- DataSeries,
242
- DataSeriesForm,
243
225
  }
244
226
 
245
227
  export {
@@ -253,6 +235,7 @@ export {
253
235
  defaultDataserviceSortOptions,
254
236
  defaultReuseSortOptions,
255
237
  defaultOrganizationSortOptions,
238
+ useSearchFilter,
256
239
  }
257
240
 
258
241
  // Vue Plugin
@@ -325,8 +308,6 @@ export {
325
308
  SchemaCard,
326
309
  SimpleBanner,
327
310
  SmallChart,
328
- ChartViewer,
329
- ChartViewerWrapper,
330
311
  StatBox,
331
312
  OpenApiViewer,
332
313
  Tab,
@@ -343,5 +324,6 @@ export {
343
324
  SearchInput,
344
325
  SearchableSelect,
345
326
  SelectGroup,
346
- Listbox,
327
+ InfiniteLoader,
328
+ TabularExplorer,
347
329
  }
@@ -1,147 +0,0 @@
1
- <template>
2
- <VChart
3
- class="w-full min-h-96"
4
- :option="echartsOption"
5
- autoresize
6
- />
7
- </template>
8
-
9
- <script setup lang="ts">
10
- import { format, use, type ComposeOption } from 'echarts/core'
11
- import { CanvasRenderer } from 'echarts/renderers'
12
- import { LineChart, BarChart, type BarSeriesOption, type LineSeriesOption } from 'echarts/charts'
13
- import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, DatasetComponent } from 'echarts/components'
14
- import VChart from 'vue-echarts'
15
- import { computed } from 'vue'
16
- import { summarize } from '../../functions/helpers'
17
- import type { Chart, XAxis, YAxis, XAxisForm, ChartForApi } from '../../types/visualizations'
18
-
19
- use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, DatasetComponent])
20
-
21
- const props = defineProps<{
22
- chart: Chart | ChartForApi
23
- series: {
24
- data: Record<string, Array<Record<string, unknown>>>
25
- columns: Record<string, Array<string>>
26
- }
27
- }>()
28
-
29
- function mapXAxisType(xAxis: XAxis | XAxisForm): 'category' | 'value' {
30
- if (!xAxis) return 'category'
31
- return (xAxis.type ?? 'discrete') === 'continuous' ? 'value' : 'category'
32
- }
33
-
34
- function buildYAxisFormatter(yAxis: YAxis): ((value: number) => string) | undefined {
35
- return (value: number) => {
36
- const v = summarize(value)
37
- if (!yAxis.unit) return v
38
- if (yAxis.unit_position === 'prefix') return `${yAxis.unit} ${v}`
39
- return `${v} ${yAxis.unit}`
40
- }
41
- }
42
-
43
- const echartsOption = computed(() => {
44
- const seriesCount = props.chart.series.length
45
- if (!props.chart.series || seriesCount === 0) return
46
-
47
- const seriesData = props.chart.series.map((s) => {
48
- const xColumn = s.column_x_name_override ?? props.chart.x_axis.column_x
49
- const yColumn = s.aggregate_y ? `${s.column_y}__${s.aggregate_y}` : s.column_y
50
- const resourceId = s.resource_id
51
-
52
- if (!xColumn || !yColumn || !resourceId || !s.type || !props.series.data[resourceId] || !props.series.columns[resourceId]) {
53
- return null
54
- }
55
-
56
- const sortedData = [...props.series.data[resourceId]]
57
- let sortBy: string | null = null
58
- let sortDirection: 'asc' | 'desc' = 'asc'
59
- sortBy = props.chart.x_axis.sort_x_by
60
- sortDirection = props.chart.x_axis.sort_x_direction ?? 'asc'
61
-
62
- if (sortBy && sortDirection && props.chart.x_axis.column_x) {
63
- const sortKey = sortBy === 'axis_x' ? xColumn : yColumn
64
- sortedData.sort((a, b) => {
65
- const valA = a[sortKey] as number
66
- const valB = b[sortKey] as number
67
- if (valA < valB) return sortDirection === 'asc' ? -1 : 1
68
- if (valA > valB) return sortDirection === 'asc' ? 1 : -1
69
- return 0
70
- })
71
- }
72
-
73
- return {
74
- series: {
75
- type: s.type === 'histogram' ? 'bar' : 'line',
76
- dimensions: s.aggregate_y ? [xColumn, yColumn] : props.series.columns[resourceId],
77
- name: s.column_y,
78
- encode: {
79
- x: xColumn,
80
- y: yColumn,
81
- },
82
- } as LineSeriesOption | BarSeriesOption,
83
- data: {
84
- source: sortedData,
85
- dimensions: s.aggregate_y ? [xColumn, yColumn] : props.series.columns[resourceId],
86
- },
87
- }
88
- }).filter(Boolean).reduce((acc: { series: Array<LineSeriesOption | BarSeriesOption>, data: Array<Record<string, unknown>> }, curr) => {
89
- if (curr) {
90
- acc.series.push(curr.series)
91
- acc.data.push(curr.data)
92
- }
93
- return acc
94
- }, {
95
- series: [],
96
- data: [],
97
- })
98
-
99
- return {
100
- dataset: [...seriesData.data],
101
- title: {
102
- text: props.chart.title,
103
- left: 'center',
104
- },
105
- tooltip: {
106
- trigger: 'axis' as const,
107
- formatter: (params: Array<{ value: Record<string, unknown>, axisValueLabel: string, seriesName: string }>) => {
108
- let tooltip = ''
109
- for (const param of params) {
110
- const keys = Object.keys(param.value)
111
- const col = keys.find(key => key.startsWith(param.seriesName))!
112
- const formatter = new Intl.NumberFormat('fr-FR')
113
- tooltip += `${format.encodeHTML(param.axisValueLabel)}: <strong>${formatter.format(Number(param.value[col]))}</strong><br>`
114
- }
115
- return tooltip
116
- },
117
- },
118
- legend: {
119
- bottom: 0,
120
- },
121
- grid: {
122
- top: 60,
123
- bottom: 40,
124
- left: 20,
125
- right: 20,
126
- containLabel: true,
127
- },
128
- xAxis: {
129
- type: mapXAxisType(props.chart.x_axis),
130
- name: (props.chart.x_axis as XAxis).column_x,
131
- },
132
- yAxis: {
133
- type: 'value' as const,
134
- name: props.chart.y_axis.label ?? undefined,
135
- min: props.chart.y_axis.min ?? undefined,
136
- max: props.chart.y_axis.max ?? undefined,
137
- axisLabel: {
138
- formatter: buildYAxisFormatter(props.chart.y_axis),
139
- },
140
- },
141
- series: seriesData.series,
142
- } satisfies ComposeOption<
143
- | BarSeriesOption
144
- | LineSeriesOption
145
- >
146
- })
147
- </script>