Skip to content

Commit 1ebca21

Browse files
graph by trim
1 parent 7818832 commit 1ebca21

File tree

3 files changed

+285
-21
lines changed

3 files changed

+285
-21
lines changed

src/components/DetailChart.jsx

Lines changed: 171 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo } from 'react';
1+
import React, { useMemo, useState, useRef, useEffect } from 'react';
22
import PriceRangeChart from './PriceRangeChart';
33
import { aggregateDates } from '../utils/dateAggregation';
44
import { aggregateMetricsForGroups, collectScalingValues } from '../utils/metricAggregation';
@@ -13,6 +13,22 @@ const sourceColors = {
1313
'plattauto': '#43a047'
1414
};
1515

16+
// Color palette for trims (similar to model colors in OverviewChart)
17+
const trimColorPalette = [
18+
'#667eea', // Primary purple
19+
'#f56565', // Red
20+
'#48bb78', // Green
21+
'#ed8936', // Orange
22+
'#4299e1', // Blue
23+
'#9f7aea', // Purple
24+
'#ed64a6', // Pink
25+
'#38b2ac', // Teal
26+
'#ecc94b', // Yellow
27+
'#fc8181', // Light red
28+
'#68d391', // Light green
29+
'#f6ad55' // Light orange
30+
];
31+
1632
const toRgba = (hex, alpha) => {
1733
if (!hex) return `rgba(102, 102, 102, ${alpha})`;
1834
let normalized = hex.replace('#', '');
@@ -40,13 +56,79 @@ export default function DetailChart({
4056
loading = false,
4157
onSelectedDatePosition
4258
}) {
59+
const [groupMode, setGroupMode] = useState(() => {
60+
// Initialize from URL parameter
61+
const url = new URL(window.location);
62+
const groupParam = url.searchParams.get('groupBy');
63+
return groupParam === 'trim' ? 'trim' : 'source';
64+
});
65+
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
66+
const groupButtonRef = useRef(null);
67+
68+
// Handle URL parameter for group mode
69+
useEffect(() => {
70+
const url = new URL(window.location);
71+
const groupParam = url.searchParams.get('groupBy');
72+
if (groupParam) {
73+
setGroupMode(groupParam === 'trim' ? 'trim' : 'source');
74+
} else {
75+
setGroupMode('source');
76+
}
77+
}, []);
78+
79+
// Handle browser back/forward for group mode
80+
useEffect(() => {
81+
const handlePopState = () => {
82+
const url = new URL(window.location);
83+
const groupParam = url.searchParams.get('groupBy');
84+
setGroupMode(groupParam === 'trim' ? 'trim' : 'source');
85+
};
86+
87+
window.addEventListener('popstate', handlePopState);
88+
return () => window.removeEventListener('popstate', handlePopState);
89+
}, []);
90+
91+
// Close dropdown when clicking outside
92+
useEffect(() => {
93+
if (!groupMenuOpen) return;
94+
const handleClickOutside = (event) => {
95+
if (groupButtonRef.current && !groupButtonRef.current.contains(event.target)) {
96+
setGroupMenuOpen(false);
97+
}
98+
};
99+
document.addEventListener('mousedown', handleClickOutside);
100+
return () => document.removeEventListener('mousedown', handleClickOutside);
101+
}, [groupMenuOpen]);
102+
43103
const { datasets, dates } = useMemo(() => {
44104
if (!data || data.length === 0 || !model) {
45105
return { datasets: [], dates: [] };
46106
}
47107

48-
// Extract sources and dates
49-
const sources = [...new Set(data.map(d => d.source))];
108+
// Extract groups (sources or trims) and dates
109+
let groups;
110+
let colorMap;
111+
112+
if (groupMode === 'trim') {
113+
// Get all unique normalized trims for this model
114+
const trimSet = new Set();
115+
data.forEach(sourceData => {
116+
sourceData.listings
117+
.filter(l => `${l.make} ${l.model}` === model && l.normalized_trim)
118+
.forEach(listing => trimSet.add(listing.normalized_trim));
119+
});
120+
groups = Array.from(trimSet).sort();
121+
122+
// Assign colors to trims
123+
colorMap = {};
124+
groups.forEach((trim, index) => {
125+
colorMap[trim] = trimColorPalette[index % trimColorPalette.length];
126+
});
127+
} else {
128+
// Use sources
129+
groups = [...new Set(data.map(d => d.source))];
130+
colorMap = sourceColors;
131+
}
50132
const providedDates = Array.isArray(dateLabels) && dateLabels.length > 0
51133
? dateLabels
52134
: null;
@@ -58,19 +140,27 @@ export default function DetailChart({
58140
const dateAggregation = aggregateDates(baseDates, availableDates);
59141
const { dates, dateGroups } = dateAggregation;
60142

61-
// Aggregate metrics for all sources, filtered by model
143+
// Aggregate metrics for all groups, filtered by model and group
62144
const aggregatedMetrics = aggregateMetricsForGroups(
63145
data,
64-
sources,
146+
groups,
65147
baseDates,
66148
dateAggregation,
67-
(sourceData, source) => {
68-
if (sourceData.source !== source) {
69-
return [];
149+
(sourceData, group) => {
150+
const modelListings = sourceData.listings.filter(l => `${l.make} ${l.model}` === model);
151+
152+
if (groupMode === 'trim') {
153+
// Filter by normalized_trim
154+
return modelListings
155+
.filter(l => l.normalized_trim === group)
156+
.map(listing => ({ ...listing, source: sourceData.source }));
157+
} else {
158+
// Filter by source
159+
if (sourceData.source !== group) {
160+
return [];
161+
}
162+
return modelListings.map(listing => ({ ...listing, source: sourceData.source }));
70163
}
71-
return sourceData.listings
72-
.filter(l => `${l.make} ${l.model}` === model)
73-
.map(listing => ({ ...listing, source: sourceData.source }));
74164
}
75165
);
76166

@@ -82,8 +172,8 @@ export default function DetailChart({
82172
: () => 5;
83173

84174
// Transform metrics into datasets
85-
const datasets = sources.map(source => {
86-
const metricsMap = aggregatedMetrics.get(source);
175+
const datasets = groups.map(group => {
176+
const metricsMap = aggregatedMetrics.get(group);
87177

88178
const avgPoints = [];
89179
const minPoints = [];
@@ -136,16 +226,21 @@ export default function DetailChart({
136226
pointRadiiDays.push(baseRadiusDays * scaleFactor);
137227
});
138228

139-
const baseColor = sourceColors[source] || '#666';
229+
const baseColor = colorMap[group] || '#666';
140230

141231
// Use dark mode detection
142232
const prefersDark = typeof window !== 'undefined' && window.matchMedia
143233
? window.matchMedia('(prefers-color-scheme: dark)').matches
144234
: false;
145235
const rangeFillColor = prefersDark ? toRgba(baseColor, 0.6) : toRgba(baseColor, 0.25);
146236

237+
// Format label based on group mode
238+
const label = groupMode === 'trim'
239+
? group
240+
: (group.charAt(0).toUpperCase() + group.slice(1));
241+
147242
return {
148-
label: source.charAt(0).toUpperCase() + source.slice(1),
243+
label,
149244
data: avgPoints,
150245
borderColor: baseColor,
151246
backgroundColor: baseColor,
@@ -158,7 +253,7 @@ export default function DetailChart({
158253
pointHitRadius: pointRadiiStock,
159254
pointBackgroundColor: baseColor,
160255
isAverageLine: true,
161-
modelName: source.charAt(0).toUpperCase() + source.slice(1),
256+
modelName: label,
162257
order: 0,
163258
z: 10,
164259
color: baseColor,
@@ -176,12 +271,69 @@ export default function DetailChart({
176271
hasAggregatedDates: dates.length !== baseDates.length
177272
};
178273
}).filter(dataset => {
179-
// Filter out sources with no data at all
274+
// Filter out groups with no data at all
180275
return dataset.data.some(price => price !== null);
181276
});
182277

183278
return { datasets, dates };
184-
}, [data, model, dateLabels, availableDates]);
279+
}, [data, model, dateLabels, availableDates, groupMode]);
280+
281+
// Group mode selector component
282+
const groupModeSelector = (
283+
<div className="group-mode-selector" ref={groupButtonRef}>
284+
<button
285+
type="button"
286+
className="group-mode-button"
287+
onClick={() => setGroupMenuOpen(!groupMenuOpen)}
288+
aria-label="Change grouping mode"
289+
aria-expanded={groupMenuOpen}
290+
>
291+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
292+
<polyline points="3,18 7,12 11,15 15,8 19,11 22,6" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
293+
</svg>
294+
</button>
295+
{groupMenuOpen && (
296+
<div className="group-mode-menu">
297+
<button
298+
type="button"
299+
className={`group-mode-menu-item${groupMode === 'source' ? ' active' : ''}`}
300+
onClick={() => {
301+
setGroupMode('source');
302+
const url = new URL(window.location);
303+
url.searchParams.delete('groupBy');
304+
window.history.pushState({}, '', url);
305+
setTimeout(() => setGroupMenuOpen(false), 300);
306+
}}
307+
>
308+
{groupMode === 'source' ? (
309+
<span className="group-mode-menu-item__check"></span>
310+
) : (
311+
<span className="group-mode-menu-item__spacer"></span>
312+
)}
313+
By Source
314+
</button>
315+
<button
316+
type="button"
317+
className={`group-mode-menu-item${groupMode === 'trim' ? ' active' : ''}`}
318+
onClick={() => {
319+
setGroupMode('trim');
320+
const url = new URL(window.location);
321+
url.searchParams.set('groupBy', 'trim');
322+
window.history.pushState({}, '', url);
323+
setTimeout(() => setGroupMenuOpen(false), 300);
324+
}}
325+
>
326+
{groupMode === 'trim' ? (
327+
<span className="group-mode-menu-item__check"></span>
328+
) : (
329+
<span className="group-mode-menu-item__spacer"></span>
330+
)}
331+
By Trim
332+
</button>
333+
</div>
334+
)}
335+
</div>
336+
);
185337

186338
return (
187339
<PriceRangeChart
@@ -198,6 +350,7 @@ export default function DetailChart({
198350
loading={loading}
199351
enableItemNavigation={false}
200352
onSelectedDatePosition={onSelectedDatePosition}
353+
extraControls={groupModeSelector}
201354
/>
202355
);
203356
}

src/components/PriceRangeChart.css

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,108 @@
5050
box-shadow: 0 6px 14px rgba(102, 126, 234, 0.25);
5151
}
5252

53+
.chart-extra-controls {
54+
display: flex;
55+
align-items: center;
56+
gap: 0.5rem;
57+
}
58+
59+
.group-mode-selector {
60+
position: relative;
61+
}
62+
63+
.group-mode-button {
64+
display: flex;
65+
align-items: center;
66+
justify-content: center;
67+
width: 36px;
68+
height: 36px;
69+
padding: 0;
70+
border: 1px solid var(--border-color);
71+
border-radius: 0.5rem;
72+
background: var(--bg-secondary);
73+
color: var(--text-secondary);
74+
cursor: pointer;
75+
transition: all 0.2s ease;
76+
}
77+
78+
.group-mode-button:hover {
79+
border-color: var(--primary-color);
80+
color: var(--primary-color);
81+
background: var(--hover-bg);
82+
}
83+
84+
.group-mode-button:focus-visible {
85+
outline: none;
86+
border-color: var(--primary-color);
87+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
88+
}
89+
90+
.group-mode-menu {
91+
position: absolute;
92+
top: calc(100% + 4px);
93+
right: 0;
94+
min-width: 160px;
95+
background: var(--bg-primary);
96+
border: 1px solid rgba(255, 255, 255, 0.15);
97+
border-radius: 0.5rem;
98+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
99+
overflow: hidden;
100+
z-index: 100;
101+
}
102+
103+
@media (prefers-color-scheme: light) {
104+
.group-mode-menu {
105+
background: var(--bg-secondary);
106+
border-color: rgba(0, 0, 0, 0.15);
107+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
108+
}
109+
}
110+
111+
.group-mode-menu-item {
112+
display: flex;
113+
align-items: center;
114+
gap: 0.5rem;
115+
width: 100%;
116+
padding: 0.65rem 0.875rem;
117+
border: none;
118+
background: transparent;
119+
color: var(--text-primary);
120+
font-size: 0.875rem;
121+
text-align: left;
122+
cursor: pointer;
123+
transition: background 0.15s ease;
124+
}
125+
126+
.group-mode-menu-item__check {
127+
color: var(--primary-color);
128+
font-weight: bold;
129+
font-size: 1rem;
130+
line-height: 1;
131+
width: 1rem;
132+
flex-shrink: 0;
133+
animation: checkAppear 0.3s ease-out;
134+
}
135+
136+
.group-mode-menu-item__spacer {
137+
width: 1rem;
138+
flex-shrink: 0;
139+
}
140+
141+
.group-mode-menu-item:hover {
142+
background: var(--hover-bg);
143+
}
144+
145+
.group-mode-menu-item.active {
146+
background: rgba(102, 126, 234, 0.15);
147+
color: var(--primary-color);
148+
font-weight: 600;
149+
}
150+
151+
.group-mode-menu-item:not(:last-child) {
152+
border-bottom: 1px solid var(--border-light);
153+
}
154+
53155
.dot-size-selector {
54156
position: relative;
55157
}
@@ -355,7 +457,12 @@
355457
flex: 1;
356458
}
357459

358-
.dot-size-selector {
460+
.chart-extra-controls {
461+
flex-shrink: 0;
462+
}
463+
464+
.dot-size-selector,
465+
.group-mode-selector {
359466
flex-shrink: 0;
360467
}
361468

0 commit comments

Comments
 (0)