Skip to content

Commit 553fcf0

Browse files
committed
improve xAxis
1 parent 0ac9fa8 commit 553fcf0

File tree

3 files changed

+288
-14
lines changed

3 files changed

+288
-14
lines changed

apps/api/src/python/visualizations-v2.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ import numpy as np
3535
import math
3636
from jinja2 import Template
3737
38+
class _BrieferNpEncoder(json.JSONEncoder):
39+
def default(self, obj):
40+
if pd.api.types.is_integer_dtype(obj):
41+
return int(obj)
42+
if pd.api.types.is_float_dtype(obj):
43+
return float(obj)
44+
if isinstance(obj, np.ndarray):
45+
return obj.tolist()
46+
try:
47+
return super(_BrieferNpEncoder, self).default(obj)
48+
except:
49+
return str(obj)
50+
3851
def _briefer_render_filter_value(filter):
3952
try:
4053
if isinstance(filter["value"], list):
@@ -520,7 +533,7 @@ def _briefer_create_visualization(df, options):
520533
"tooManyDataPoints": too_many_data_points,
521534
"filters": options["filters"]
522535
}
523-
}, default=str)
536+
}, cls=_BrieferNpEncoder)
524537
525538
print(output)
526539

apps/web/src/components/v2Editor/customBlocks/visualizationV2/VisualizationView.tsx

+273-13
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as dfns from 'date-fns'
12
import * as echarts from 'echarts-unofficial-v6'
23
import { fontFamily as twFontFamiliy } from 'tailwindcss/defaultTheme'
34
import { format as d3Format } from 'd3-format'
@@ -12,7 +13,7 @@ import {
1213
} from '@heroicons/react/20/solid'
1314
import { CubeTransparentIcon } from '@heroicons/react/24/solid'
1415
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
15-
import { DataFrame } from '@briefer/types'
16+
import { DataFrame, TimeUnit } from '@briefer/types'
1617
import LargeSpinner from '@/components/LargeSpinner'
1718
import clsx from 'clsx'
1819
import useSideBar from '@/hooks/useSideBar'
@@ -23,6 +24,7 @@ import { findMaxFontSize, measureText } from '@/measureText'
2324
import {
2425
VisualizationV2BlockInput,
2526
VisualizationV2BlockOutputResult,
27+
XAxis,
2628
} from '@briefer/editor'
2729
import { head } from 'ramda'
2830

@@ -269,16 +271,16 @@ function BrieferResult(props: {
269271
left: !props.hasControls ? 18 : 'center',
270272
},
271273
grid,
272-
xAxis: props.result.xAxis.map((axis) => ({
273-
...axis,
274-
axisLabel: {
275-
hideOverlap: true,
276-
interval: axis.type === 'category' ? 0 : 'auto',
277-
},
278-
splitLine: {
279-
show: false,
280-
},
281-
})),
274+
xAxis: props.result.xAxis.map((axis) => {
275+
switch (axis.type) {
276+
case 'value':
277+
return getValueAxis(axis, props.result)
278+
case 'time':
279+
return getTimeAxis(axis, props.result)
280+
case 'category':
281+
return getCategoryAxis(axis)
282+
}
283+
}),
282284
yAxis: props.result.yAxis.map((axis) => ({
283285
...axis,
284286
})),
@@ -337,7 +339,7 @@ function Echarts(props: EchartsProps) {
337339
const hiddenChart = echarts.init(hiddenRef.current, null, {
338340
renderer: 'svg',
339341
})
340-
hiddenChart.setOption({
342+
const hiddenChartOption = {
341343
...props.option,
342344
// set animation to be as fast as possible, since finished event does not get fired when no animation
343345
animationDelay: 0,
@@ -354,7 +356,9 @@ function Echarts(props: EchartsProps) {
354356
hideOverlap: false,
355357
},
356358
})),
357-
})
359+
}
360+
361+
hiddenChart.setOption(hiddenChartOption)
358362

359363
const handleFinished = () => {
360364
const xAxes = Array.isArray(props.option.xAxis)
@@ -662,4 +666,260 @@ function BigNumberVisualization(props: {
662666
}
663667
}
664668

669+
function getValuesMinInterval(xValues: number[]): number {
670+
if (xValues.length === 0) {
671+
return 0
672+
}
673+
674+
let minDiff = Infinity
675+
676+
for (let i = 0; i < xValues.length - 1; i++) {
677+
const a = xValues[i]
678+
const b = xValues[i + 1]
679+
if (a === b) {
680+
continue
681+
}
682+
683+
const diff = Math.abs(a - b)
684+
if (diff < minDiff) {
685+
minDiff = diff
686+
}
687+
}
688+
689+
return minDiff
690+
}
691+
692+
function getValueAxis(axis: XAxis, result: VisualizationV2BlockOutputResult) {
693+
let interval: 'auto' | number = 'auto'
694+
695+
let min = -Infinity
696+
let max = Infinity
697+
const xFields = result.series
698+
.map((s) => s.encode?.x)
699+
.filter((x): x is string | number => x !== undefined)
700+
const values = result.dataset
701+
.flatMap((d) =>
702+
xFields.flatMap((f) => d.source.flatMap((r) => r[f.toString()]))
703+
)
704+
.filter((v) => typeof v === 'number')
705+
706+
if (values.length > 0) {
707+
interval = getValuesMinInterval(values)
708+
min = Math.min(...values)
709+
max = Math.max(...values)
710+
}
711+
712+
return {
713+
...axis,
714+
axisLabel: {
715+
margin: 5,
716+
hideOverlap: true,
717+
...(typeof interval === 'number'
718+
? {
719+
formatter: (value: string | number): string => {
720+
if (typeof value === 'number' && (value < min || value > max)) {
721+
return ''
722+
}
723+
724+
return value.toString()
725+
},
726+
}
727+
: {}),
728+
},
729+
min: interval !== 'auto' ? min - interval / 2 : 'dataMin',
730+
max: interval !== 'auto' ? max + interval / 2 : 'dataMax',
731+
...(interval !== 'auto'
732+
? {
733+
minInterval: interval,
734+
}
735+
: {}),
736+
splitLine: {
737+
show: false,
738+
},
739+
}
740+
}
741+
742+
function getTimeAxis(axis: XAxis, result: VisualizationV2BlockOutputResult) {
743+
const intervalOrder: {
744+
[T in TimeUnit]: number
745+
} = {
746+
seconds: 0,
747+
minutes: 1,
748+
hours: 2,
749+
date: 3,
750+
week: 4,
751+
month: 5,
752+
quarter: 6,
753+
year: 7,
754+
}
755+
756+
const xFields = result.series
757+
.map((s) => s.encode?.x)
758+
.filter((x): x is string | number => x !== undefined)
759+
const values = result.dataset
760+
.flatMap((d) =>
761+
xFields.flatMap((f) => d.source.flatMap((r) => new Date(r[f.toString()])))
762+
)
763+
.filter((date) => dfns.isValid(date))
764+
765+
let min = values[0]
766+
let max = values[0]
767+
let minIntervalUnit: TimeUnit = 'year'
768+
for (let i = 0; i < values.length - 1; i++) {
769+
const a = values[i]
770+
const b = values[i + 1]
771+
if (!a || !b || !dfns.isValid(a) || !dfns.isValid(b)) {
772+
continue
773+
}
774+
775+
const intervalUnit = getIntervalUnit(a, b)
776+
777+
if (intervalOrder[intervalUnit] < intervalOrder[minIntervalUnit]) {
778+
minIntervalUnit = intervalUnit
779+
}
780+
781+
min =
782+
a.getTime() < min.getTime() ? a : b.getTime() < min.getTime() ? b : min
783+
max =
784+
a.getTime() > max.getTime() ? a : b.getTime() > max.getTime() ? b : max
785+
}
786+
787+
const hideDay = values.every((v) => {
788+
switch (minIntervalUnit) {
789+
case 'year':
790+
return dfns.getDate(v) === 1 && dfns.getMonth(v) === 0
791+
case 'quarter':
792+
return dfns.getDate(v) === 1 && dfns.getMonth(v) % 3 === 0
793+
case 'month':
794+
return dfns.isFirstDayOfMonth(v)
795+
case 'week':
796+
case 'date':
797+
case 'hours':
798+
case 'minutes':
799+
case 'seconds':
800+
return false
801+
}
802+
})
803+
804+
const minInterval: number = (() => {
805+
switch (minIntervalUnit) {
806+
case 'year':
807+
return 365 * 24 * 60 * 60 * 1000
808+
case 'quarter':
809+
return 91 * 24 * 60 * 60 * 1000
810+
case 'month':
811+
return 30 * 24 * 60 * 60 * 1000
812+
case 'week':
813+
return 7 * 24 * 60 * 60 * 1000
814+
case 'date':
815+
return 24 * 60 * 60 * 1000
816+
case 'hours':
817+
return 60 * 60 * 1000
818+
case 'minutes':
819+
return 60 * 1000
820+
case 'seconds':
821+
return 1000
822+
}
823+
})()
824+
825+
return {
826+
...axis,
827+
axisLabel: {
828+
margin: 5,
829+
hideOverlap: true,
830+
formatter: (value: string | number): string => {
831+
const asDate = new Date(value)
832+
if (asDate < min || asDate > max) {
833+
return ''
834+
}
835+
836+
switch (minIntervalUnit) {
837+
case 'year':
838+
if (hideDay) {
839+
return dfns.format(value, 'yyyy')
840+
} else {
841+
return dfns.format(value, 'MMMM d, yyyy')
842+
}
843+
case 'quarter':
844+
case 'month':
845+
case 'week':
846+
case 'date':
847+
if (hideDay) {
848+
return dfns.format(value, 'MMMM yyyy')
849+
} else {
850+
return dfns.format(value, 'MMMM d, yyyy')
851+
}
852+
case 'hours':
853+
case 'minutes':
854+
return dfns.format(value, 'MMMM d, yyyy, h:mm a')
855+
case 'seconds':
856+
return dfns.format(value, 'MMMM d, yyyy, h:mm:ss a')
857+
}
858+
},
859+
showMaxLabel: true,
860+
showMinLabel: true,
861+
},
862+
axisTick: {
863+
show: false,
864+
},
865+
min: min.getTime() - minInterval / 2,
866+
max: max.getTime() + minInterval / 2,
867+
minInterval,
868+
splitLine: {
869+
show: false,
870+
},
871+
}
872+
}
873+
874+
function getIntervalUnit(a: Date, b: Date): TimeUnit {
875+
const years = Math.abs(dfns.differenceInYears(b, a))
876+
if (years >= 1) {
877+
return 'year'
878+
}
879+
880+
const months = Math.abs(dfns.differenceInMonths(b, a))
881+
if (months >= 3) {
882+
return 'quarter'
883+
}
884+
885+
if (months >= 1) {
886+
return 'month'
887+
}
888+
889+
const weeks = Math.abs(dfns.differenceInWeeks(b, a))
890+
if (weeks >= 1) {
891+
return 'week'
892+
}
893+
894+
const days = Math.abs(dfns.differenceInDays(b, a))
895+
if (days >= 1) {
896+
return 'date'
897+
}
898+
899+
const hours = Math.abs(dfns.differenceInHours(b, a))
900+
if (hours >= 1) {
901+
return 'hours'
902+
}
903+
904+
const minutes = Math.abs(dfns.differenceInMinutes(b, a))
905+
if (minutes >= 1) {
906+
return 'minutes'
907+
}
908+
909+
return 'seconds'
910+
}
911+
912+
function getCategoryAxis(axis: XAxis) {
913+
return {
914+
...axis,
915+
axisLabel: {
916+
hideOverlap: true,
917+
interval: 0,
918+
},
919+
splitLine: {
920+
show: false,
921+
},
922+
}
923+
}
924+
665925
export default VisualizationViewV2

packages/editor/src/blocks/visualization-v2.ts

+1
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ const XAxis = CartesianAxisOption.and(
170170
}),
171171
})
172172
)
173+
export type XAxis = z.infer<typeof XAxis>
173174

174175
const YAxis = CartesianAxisOption.and(
175176
z.object({

0 commit comments

Comments
 (0)