Skip to content

Commit c37fa8f

Browse files
committed
Draw dashed partial periods at the start and end of main series
1 parent 6d72570 commit c37fa8f

File tree

4 files changed

+135
-114
lines changed

4 files changed

+135
-114
lines changed

assets/js/dashboard/components/graph.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,6 @@ function InnerGraph<T extends ReadonlyArray<number | null>>({
235235
line.stopIndexExclusive !== undefined
236236
? i < line.stopIndexExclusive
237237
: true
238-
console.log(i, line.lineType, [line.startIndexInclusive,line.stopIndexExclusive],{valueDefined, atOrOverStart, beforeEnd})
239238
return valueDefined && atOrOverStart && beforeEnd
240239
},
241240
xAccessor: (_d, index) => x(index),

assets/js/dashboard/stats/graph/main-graph-data.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
getChangeInPercentagePoints,
3-
getRelativeChange
3+
getRelativeChange,
4+
getLineSegments
45
} from './main-graph-data'
56

67
describe(`${getChangeInPercentagePoints.name}`, () => {
@@ -38,3 +39,75 @@ describe(`${getRelativeChange.name}`, () => {
3839
expect(getRelativeChange(50, 100)).toBe(-50)
3940
})
4041
})
42+
43+
const np = (value = 0) => ({ value, isPartial: false, timeLabel: '' })
44+
const p = (value = 0) => ({ value, isPartial: true, timeLabel: '' })
45+
const gap = () => ({ value: null, isPartial: null, timeLabel: null })
46+
47+
describe(`${getLineSegments.name}`, () => {
48+
it('returns empty for empty input', () => {
49+
expect(getLineSegments([])).toEqual([])
50+
})
51+
52+
it('returns empty for a single point (no edge to draw)', () => {
53+
expect(getLineSegments([np()])).toEqual([])
54+
})
55+
56+
it('returns empty for a single gap', () => {
57+
expect(getLineSegments([gap()])).toEqual([])
58+
})
59+
60+
it('returns a full segment for two non-partial points', () => {
61+
expect(getLineSegments([np(), np()])).toEqual([
62+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'full' }
63+
])
64+
})
65+
66+
it('returns a partial segment for two partial points', () => {
67+
expect(getLineSegments([p(), p()])).toEqual([
68+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' }
69+
])
70+
})
71+
72+
it('returns partial when connecting non-partial to partial', () => {
73+
expect(getLineSegments([np(), p()])).toEqual([
74+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' }
75+
])
76+
})
77+
78+
it('returns partial when connecting partial to non-partial', () => {
79+
expect(getLineSegments([p(), np()])).toEqual([
80+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' }
81+
])
82+
})
83+
84+
it('handles single full period in the middle of two partial periods', () => {
85+
expect(getLineSegments([p(), np(), p()])).toEqual([
86+
{ startIndexInclusive: 0, stopIndexExclusive: 3, type: 'partial' }
87+
])
88+
})
89+
90+
it('handles partial periods on both ends', () => {
91+
expect(getLineSegments([p(), np(), np(), p()])).toEqual([
92+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'partial' },
93+
{ startIndexInclusive: 1, stopIndexExclusive: 3, type: 'full' },
94+
{ startIndexInclusive: 2, stopIndexExclusive: 4, type: 'partial' }
95+
])
96+
})
97+
98+
it('handles leading gaps', () => {
99+
expect(
100+
getLineSegments([gap(), gap(), np(), np(), np(), np(), p()])
101+
).toEqual([
102+
{ startIndexInclusive: 2, stopIndexExclusive: 6, type: 'full' },
103+
{ startIndexInclusive: 5, stopIndexExclusive: 7, type: 'partial' }
104+
])
105+
})
106+
107+
it('handles trailing gaps', () => {
108+
expect(getLineSegments([np(), np(), p(), gap(), gap()])).toEqual([
109+
{ startIndexInclusive: 0, stopIndexExclusive: 2, type: 'full' },
110+
{ startIndexInclusive: 1, stopIndexExclusive: 3, type: 'partial' }
111+
])
112+
})
113+
})

assets/js/dashboard/stats/graph/main-graph-data.ts

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const remapAndFillData = ({
144144
comparisonTimeLabel,
145145
change,
146146
isPartial
147-
}
147+
} as GraphDatum
148148
})
149149

150150
return {
@@ -186,61 +186,64 @@ export const getRelativeChange = (
186186
return Math.round(((value - comparisonValue) / comparisonValue) * 100)
187187
}
188188

189-
type Slice = {
189+
export type LineSegment = {
190190
startIndexInclusive: number
191-
endIndexExclusive: number
192-
isPartialLine: boolean
191+
stopIndexExclusive: number
192+
type: 'full' | 'partial'
193193
}
194194

195-
// slices [B, A, A, A, A, B, B, B] to [0, 1], [1, 5], [5, 8]
196-
export function getSlices(
197-
data: { value: number | null; isPartial: boolean | null }[]
198-
): Slice[] {
199-
const slices: Slice[] = []
200-
let currentSlice: Slice | null = null
201-
202-
data.forEach((datum, index) => {
203-
if (datum.value !== null) {
204-
if (!currentSlice) {
205-
currentSlice = {
206-
startIndexInclusive: index,
207-
endIndexExclusive: index + 1,
208-
isPartialLine: datum.isPartial ?? false
209-
}
210-
} else {
211-
if (datum.isPartial === currentSlice.isPartialLine) {
212-
currentSlice.endIndexExclusive = index + 1
213-
} else {
214-
slices.push(currentSlice)
215-
currentSlice = {
216-
startIndexInclusive: index,
217-
endIndexExclusive: index + 1,
218-
isPartialLine: datum.isPartial ?? false
219-
}
220-
}
221-
}
195+
// Computes drawable line segments from a series of points.
196+
// A segment's dash style is determined by its edges: dashed if either endpoint
197+
// is partial, solid if both are non-partial. Boundary points between a solid
198+
// and a dashed segment are shared (appear as the end of one and start of the next).
199+
export function getLineSegments(data: MainSeriesValue[]): LineSegment[] {
200+
return data.reduce((segments: LineSegment[], point, i) => {
201+
if (i === 0) {
202+
return segments
203+
}
204+
const prev = data[i - 1]
205+
if (prev.value === null || point.value === null) {
206+
return segments
222207
}
223-
})
224208

225-
if (currentSlice) {
226-
slices.push(currentSlice)
227-
}
209+
const type = prev.isPartial || point.isPartial ? 'partial' : 'full'
210+
const lastSegment = segments[segments.length - 1]
228211

229-
return slices
212+
if (lastSegment?.type === type && lastSegment.stopIndexExclusive === i) {
213+
return [
214+
...segments.slice(0, -1),
215+
{ ...lastSegment, stopIndexExclusive: i + 1 }
216+
]
217+
}
218+
219+
return [
220+
...segments,
221+
{ startIndexInclusive: i - 1, stopIndexExclusive: i + 1, type }
222+
]
223+
}, [])
230224
}
231225

232226
/**
233227
* A data point for the graph and tooltip.
234228
* It's x position is its index in `GraphDatum[]` array.
235229
* The values for `value`, `comparisonValue` should be plotted on the y axis, when they are defined for the x position.
236230
*/
237-
type GraphDatum = {
238-
/** When `value` is null, it means the main series isn't defined in this x position */
239-
value: number | null
240-
timeLabel: string | null
241-
isPartial: boolean | null
242-
/** When `comparisonValue` is null, it means the comparison series isn't defined in this x position */
243-
comparisonValue?: number | null
244-
comparisonTimeLabel?: string | null
231+
export type GraphDatum = {
245232
change?: number | null
233+
} & MainSeriesValue &
234+
ComparisonSeriesValue
235+
236+
type NotDefinedValue = { value: null; isPartial: null; timeLabel: null }
237+
type DefinedValue = { value: number; isPartial: boolean; timeLabel: string }
238+
type MainSeriesValue = NotDefinedValue | DefinedValue
239+
240+
type NotDefinedComparisonValue = {
241+
comparisonValue: null
242+
isPartial: null
243+
comparisonTimeLabel: null
244+
}
245+
type DefinedComparisonValue = {
246+
comparisonValue: number
247+
comparisonTimeLabel: string
246248
}
249+
type ComparisonSeriesValue = NotDefinedComparisonValue | DefinedComparisonValue

assets/js/dashboard/stats/graph/main-graph.tsx

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { Graph, PointerHandler, SeriesConfig } from '../../components/graph'
2828
import { useSiteContext, PlausibleSite } from '../../site-context'
2929
import { GraphTooltipWrapper } from '../../components/graph-tooltip'
3030
import { MainGraphResponse } from './fetch-main-graph'
31-
import { remapAndFillData } from './main-graph-data'
31+
import { remapAndFillData, getLineSegments, GraphDatum } from './main-graph-data'
3232

3333
const height = 368
3434
const marginTop = 16
@@ -37,22 +37,6 @@ const marginBottom = 32
3737
const defaultMarginLeft = 16 // this is adjusted by the Graph component based on y-axis label width
3838
const hoverBuffer = 4
3939

40-
/**
41-
* A data point for the graph and tooltip:
42-
* it's x position is its index in GraphDatum[] array,
43-
* y positions are value, comparisonValue.
44-
* Remapped from @see MainGraphResponse to fill empty buckets that BE
45-
* doesn't return.
46-
*/
47-
type GraphDatum = {
48-
value: number | null
49-
isPartial: boolean | null
50-
timeLabel: string | null
51-
comparisonValue?: number | null
52-
comparisonTimeLabel?: string | null
53-
change?: number | null
54-
}
55-
5640
type MainGraphData = MainGraphResponse & {
5741
period: DashboardPeriod
5842
interval: string
@@ -100,39 +84,11 @@ export const MainGraph = ({
10084

10185
const gradients = [primaryGradient, secondaryGradient]
10286

103-
const slices: {
104-
startIndexInclusive: number
105-
stopIndexExclusive: number
106-
lineType: 'solid' | 'dashed' | 'gap'
107-
}[] = []
108-
let slice: {
109-
startIndexInclusive: number
110-
stopIndexExclusive: number
111-
lineType: 'solid' | 'dashed' | 'gap'
112-
} | null = null
87+
const lineSegments = getLineSegments(remappedData)
88+
11389
// can't be done in a single pass with remapAndFillData
11490
// because we need the xLabels formatting parameters to be known
11591
const remappedDataInGraphFormat = remappedData.map((d, bucketIndex) => {
116-
const lineType =
117-
d.value === null ? 'gap' : d.isPartial ? 'dashed' : 'solid'
118-
console.log(lineType)
119-
120-
if (slice && slice.lineType !== lineType) {
121-
slice.stopIndexExclusive = bucketIndex
122-
slices.push(slice)
123-
slice = null
124-
}
125-
126-
if (slice) {
127-
slice.stopIndexExclusive = bucketIndex + 1
128-
} else {
129-
slice = {
130-
startIndexInclusive: bucketIndex,
131-
stopIndexExclusive: bucketIndex + 1,
132-
lineType
133-
}
134-
}
135-
13692
const dataPoint = {
13793
values: [d.value ?? null, d.comparisonValue ?? null] as const,
13894
xLabel:
@@ -155,30 +111,20 @@ export const MainGraph = ({
155111

156112
return dataPoint
157113
})
158-
if (slice !== null) {
159-
slices.push(slice)
160-
}
161114

162-
console.log(slices)
163115
const mainSeries: SeriesConfig = {
164-
lines: slices
165-
.filter((s) => s.lineType === 'solid' || s.lineType === 'dashed')
166-
.map((s) => ({
167-
startIndexInclusive: s.startIndexInclusive,
168-
stopIndexExclusive: s.stopIndexExclusive + 1,
169-
lineClassName: classNames(
170-
sharedPathClass,
171-
mainPathClass,
172-
{ dashed: dashedPathClass, solid: roundedPathClass }[
173-
s.lineType as 'solid' | 'dashed'
174-
]
175-
),
176-
lineType: s.lineType
177-
})),
116+
lines: lineSegments.map((s) => ({
117+
startIndexInclusive: s.startIndexInclusive,
118+
stopIndexExclusive: s.stopIndexExclusive,
119+
lineClassName: classNames(
120+
sharedPathClass,
121+
mainPathClass,
122+
s.type === 'partial' ? dashedPathClass : roundedPathClass
123+
)
124+
})),
178125
underline: { gradientId: primaryGradient.id },
179126
dot: { dotClassName: classNames(sharedDotClass, mainDotClass) }
180127
}
181-
console.log(mainSeries.lines)
182128

183129
const comparisonSeries: SeriesConfig = {
184130
lines: [
@@ -191,8 +137,8 @@ export const MainGraph = ({
191137
}
192138

193139
const settings: [SeriesConfig, SeriesConfig] = [
194-
mainSeries
195-
// comparisonSeries
140+
mainSeries,
141+
comparisonSeries
196142
]
197143

198144
const yearIsUnambiguous = isYearUnambiguous({

0 commit comments

Comments
 (0)