Skip to content

Commit 468d0d0

Browse files
authored
Merge pull request #5 from youenchene/updates
feat: Enhance LineChart with Y-axis title, band labels, and UCL/LCL features
2 parents 719b46a + 357239f commit 468d0d0

3 files changed

Lines changed: 209 additions & 9 deletions

File tree

ui/src/App.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,24 @@ function ThroughputBlock() {
365365
<Card>
366366
<CardContent>
367367
<div ref={containerRef} className="w-full">
368-
<LineChart series={[main, lcl, ucl]} width={Math.max(320, containerWidth)} height={260} colors={["#000", "#9ca3af", "#9ca3af"]} xTickCount={5} />
368+
<LineChart
369+
series={[main, lcl, ucl]}
370+
width={Math.max(320, containerWidth)}
371+
height={260}
372+
colors={["#000", "#9ca3af", "#9ca3af"]}
373+
xTickCount={5}
374+
yAxisTitle={t('throughput.yAxisTitle')}
375+
uclLabel={t('throughput.ucl')}
376+
lclLabel={t('throughput.lcl')}
377+
/>
369378
</div>
379+
{/* UCL/LCL definitions under the chart */}
380+
<p className="mt-2 text-xs text-gray-500">
381+
<span className="font-medium">{t('throughput.ucl')}</span> = {t('throughput.uclFull')};{' '}
382+
<span className="font-medium">{t('throughput.lcl')}</span> = {t('throughput.lclFull')}
383+
</p>
384+
<p className="mt-2 text-xs text-gray-500">{t('throughput.commonCauseDescription')}</p>
385+
<p className="mt-2 text-xs text-gray-500">{t('throughput.specialCauseDescription')}</p>
370386
</CardContent>
371387
</Card>
372388
</section>

ui/src/components/LineChart.tsx

Lines changed: 184 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export function LineChart({
1111
showAxes = true,
1212
xTickCount,
1313
showOOCMarkers = true,
14+
yAxisTitle,
15+
showBandLabels = true,
16+
uclLabel = 'UCL',
17+
lclLabel = 'LCL',
1418
}: {
1519
series: Point[][]
1620
width?: number
@@ -20,18 +24,25 @@ export function LineChart({
2024
showAxes?: boolean
2125
xTickCount?: number
2226
showOOCMarkers?: boolean
27+
yAxisTitle?: string
28+
showBandLabels?: boolean
29+
uclLabel?: string
30+
lclLabel?: string
2331
}) {
2432
const allValues = series.flat().map((p) => p.value).filter((n) => Number.isFinite(n))
2533
if (allValues.length === 0) return <svg width={width} height={height} />
2634
const min = Math.min(...allValues)
2735
const max = Math.max(...allValues)
2836
const rng = max - min || 1
2937
const count = Math.max(0, Math.max(...series.map((s) => s.length)))
30-
const padding = 32
31-
const innerW = width - padding * 2
38+
const basePadding = 32
39+
// Add some extra left padding when a Y-axis title is present so it doesn't overlap ticks
40+
const leftPad = basePadding + (yAxisTitle ? 28 : 0)
41+
const padding = basePadding
42+
const innerW = width - leftPad - padding
3243
const innerH = height - padding * 2
3344

34-
const x = (i: number) => padding + (count <= 1 ? innerW / 2 : (i * innerW) / (count - 1))
45+
const x = (i: number) => leftPad + (count <= 1 ? innerW / 2 : (i * innerW) / (count - 1))
3546
const y = (v: number) => padding + (innerH - ((v - min) / rng) * innerH)
3647

3748
// dynamic x ticks; if xTickCount provided, force that count; otherwise 5 to 10 depending on width
@@ -61,14 +72,27 @@ export function LineChart({
6172
<svg width={width} height={height} className="chart-grayscale">
6273
{showAxes && (
6374
<g>
75+
{/* optional Y-axis title */}
76+
{yAxisTitle && (
77+
<text
78+
x={12}
79+
y={height / 2}
80+
transform={`rotate(-90 12 ${height / 2})`}
81+
textAnchor="middle"
82+
fontSize="11"
83+
fill="#111827"
84+
>
85+
{yAxisTitle}
86+
</text>
87+
)}
6488
{/* axes */}
65-
<line x1={padding} y1={padding} x2={padding} y2={height - padding} stroke="#e5e7eb" />
66-
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="#e5e7eb" />
89+
<line x1={leftPad} y1={padding} x2={leftPad} y2={height - padding} stroke="#e5e7eb" />
90+
<line x1={leftPad} y1={height - padding} x2={width - padding} y2={height - padding} stroke="#e5e7eb" />
6791
{/* y ticks */}
6892
{yTickVals.map((v, i) => (
6993
<g key={i}>
70-
<line x1={padding - 4} y1={y(v)} x2={padding} y2={y(v)} stroke="#e5e7eb" />
71-
<text x={8} y={y(v)} textAnchor="start" alignmentBaseline="middle" fontSize="10" fill="#6b7280">
94+
<line x1={leftPad - 4} y1={y(v)} x2={leftPad} y2={y(v)} stroke="#e5e7eb" />
95+
<text x={leftPad - 4 - 6} y={y(v)} textAnchor="end" alignmentBaseline="middle" fontSize="10" fill="#6b7280">
7296
{v.toFixed(0)}
7397
</text>
7498
</g>
@@ -84,6 +108,109 @@ export function LineChart({
84108
))}
85109
</g>
86110
)}
111+
{/* background bands: light blue/cyan between LCL and UCL, light red outside */}
112+
{series.length >= 3 && (
113+
<g>
114+
{(() => {
115+
const lcl = series[1]
116+
const ucl = series[2]
117+
// helpers to build polygons for contiguous finite ranges
118+
const isFiniteAt = (s: Point[], i: number) => Number.isFinite(s?.[i]?.value)
119+
const buildBandPolys = () => {
120+
const polys: string[] = []
121+
let start = -1
122+
const last = Math.max(lcl.length, ucl.length) - 1
123+
for (let i = 0; i <= last; i++) {
124+
const ok = isFiniteAt(lcl, i) && isFiniteAt(ucl, i)
125+
if (ok && start === -1) start = i
126+
if ((!ok || i === last) && start !== -1) {
127+
const end = ok && i === last ? i : i - 1
128+
// build polygon from UCL (start->end) then back on LCL (end->start)
129+
const parts: string[] = []
130+
parts.push(`M${x(start).toFixed(2)},${y(ucl[start].value).toFixed(2)}`)
131+
for (let k = start + 1; k <= end; k++) {
132+
parts.push(`L${x(k).toFixed(2)},${y(ucl[k].value).toFixed(2)}`)
133+
}
134+
for (let k = end; k >= start; k--) {
135+
parts.push(`L${x(k).toFixed(2)},${y(lcl[k].value).toFixed(2)}`)
136+
}
137+
parts.push('Z')
138+
polys.push(parts.join(' '))
139+
start = -1
140+
}
141+
}
142+
return polys
143+
}
144+
const buildTopPolys = () => {
145+
const polys: string[] = []
146+
let start = -1
147+
const last = ucl.length - 1
148+
for (let i = 0; i <= last; i++) {
149+
const ok = isFiniteAt(ucl, i)
150+
if (ok && start === -1) start = i
151+
if ((!ok || i === last) && start !== -1) {
152+
const end = ok && i === last ? i : i - 1
153+
const parts: string[] = []
154+
// along top edge from start to end
155+
parts.push(`M${x(start).toFixed(2)},${padding.toFixed(2)}`)
156+
parts.push(`L${x(end).toFixed(2)},${padding.toFixed(2)}`)
157+
// back along UCL from end to start
158+
for (let k = end; k >= start; k--) {
159+
parts.push(`L${x(k).toFixed(2)},${y(ucl[k].value).toFixed(2)}`)
160+
}
161+
parts.push('Z')
162+
polys.push(parts.join(' '))
163+
start = -1
164+
}
165+
}
166+
return polys
167+
}
168+
const buildBottomPolys = () => {
169+
const polys: string[] = []
170+
let start = -1
171+
const last = lcl.length - 1
172+
for (let i = 0; i <= last; i++) {
173+
const ok = isFiniteAt(lcl, i)
174+
if (ok && start === -1) start = i
175+
if ((!ok || i === last) && start !== -1) {
176+
const end = ok && i === last ? i : i - 1
177+
const parts: string[] = []
178+
// along bottom edge from start to end
179+
const bottom = (height - padding).toFixed(2)
180+
parts.push(`M${x(start).toFixed(2)},${bottom}`)
181+
parts.push(`L${x(end).toFixed(2)},${bottom}`)
182+
// back along LCL from end to start
183+
for (let k = end; k >= start; k--) {
184+
parts.push(`L${x(k).toFixed(2)},${y(lcl[k].value).toFixed(2)}`)
185+
}
186+
parts.push('Z')
187+
polys.push(parts.join(' '))
188+
start = -1
189+
}
190+
}
191+
return polys
192+
}
193+
194+
const greenPolys = buildBandPolys()
195+
const topRedPolys = buildTopPolys()
196+
const bottomRedPolys = buildBottomPolys()
197+
198+
return (
199+
<>
200+
{topRedPolys.map((d, i) => (
201+
<path key={`rt-${i}`} d={d} fill="#EF4444" fillOpacity={0.06} stroke="none" />
202+
))}
203+
{greenPolys.map((d, i) => (
204+
<path key={`gn-${i}`} d={d} fill="#06B6D4" fillOpacity={0.06} stroke="none" />
205+
))}
206+
{bottomRedPolys.map((d, i) => (
207+
<path key={`rb-${i}`} d={d} fill="#EF4444" fillOpacity={0.06} stroke="none" />
208+
))}
209+
</>
210+
)
211+
})()}
212+
</g>
213+
)}
87214
{/* series paths */}
88215
{paths}
89216
{/* out-of-control markers: when main > UCL or main < LCL */}
@@ -115,6 +242,56 @@ export function LineChart({
115242
})}
116243
</g>
117244
)}
245+
{/* UCL/LCL labels at right edge near the last points */}
246+
{showBandLabels && series.length >= 3 && (
247+
<g>
248+
{(() => {
249+
// helper to find the last finite value index in a series
250+
const lastFiniteIndex = (s: Point[]) => {
251+
for (let i = s.length - 1; i >= 0; i--) {
252+
if (Number.isFinite(s[i]?.value)) return i
253+
}
254+
return -1
255+
}
256+
const lclSeries = series[1] || []
257+
const uclSeries = series[2] || []
258+
const li = lastFiniteIndex(lclSeries)
259+
const ui = lastFiniteIndex(uclSeries)
260+
const nodes: React.ReactNode[] = []
261+
if (ui >= 0) {
262+
const xv = Math.min(x(ui) + 6, width - padding - 2)
263+
const yv = y(uclSeries[ui].value)
264+
nodes.push(
265+
<text
266+
key="ucl-label"
267+
x={xv}
268+
y={Math.max(padding + 10, yv - 6)}
269+
fontSize={10}
270+
fill="#374151"
271+
>
272+
{uclLabel}
273+
</text>
274+
)
275+
}
276+
if (li >= 0) {
277+
const xv = Math.min(x(li) + 6, width - padding - 2)
278+
const yv = y(lclSeries[li].value)
279+
nodes.push(
280+
<text
281+
key="lcl-label"
282+
x={xv}
283+
y={Math.min(height - padding - 4, Math.max(padding + 10, yv - 6))}
284+
fontSize={10}
285+
fill="#374151"
286+
>
287+
{lclLabel}
288+
</text>
289+
)
290+
}
291+
return nodes
292+
})()}
293+
</g>
294+
)}
118295
</svg>
119296
)
120297
}

ui/src/locales/en/translation.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@
3232
}
3333
},
3434
"throughput": {
35-
"sectionTitle": "Control Chart"
35+
"sectionTitle": "Control Chart",
36+
"yAxisTitle": "Number of Issues per Week",
37+
"ucl": "UCL",
38+
"lcl": "LCL",
39+
"uclFull": "Upper Control Limit",
40+
"lclFull": "Lower Control Limit",
41+
"commonCauseDescription": "Points inside the LCL and UCL range is considered as common cause, which does not need to be addressed as it is a normal behavior of the system.",
42+
"specialCauseDescription": "Points outside the LCL and UCL range is considered as special cause, which needs to be addressed and investigated. (Marked by a red circle)"
3643
},
3744
"chart": {
3845
"total": "Total"

0 commit comments

Comments
 (0)