feat([2fd88f42]): implement tooltip column manager
This commit is contained in:
@@ -42,14 +42,20 @@ def load_plz_data():
|
||||
plz_geocoord_df = pd.DataFrame()
|
||||
|
||||
# --- Pydantic Models ---
|
||||
class TooltipColumnConfig(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
visible: bool
|
||||
|
||||
class FilterRequest(BaseModel):
|
||||
filters: Dict[str, List[str]]
|
||||
tooltip_config: List[TooltipColumnConfig] = []
|
||||
|
||||
class PlzColumnRequest(BaseModel):
|
||||
plz_column: str
|
||||
|
||||
# --- API Endpoints ---
|
||||
@app.get("/")
|
||||
@app.get("/ ")
|
||||
def read_root():
|
||||
return {"message": "Heatmap Tool Backend"}
|
||||
|
||||
@@ -146,15 +152,22 @@ async def get_heatmap_data(request: FilterRequest):
|
||||
plz_grouped = filtered_df.groupby(plz_column_name)
|
||||
plz_counts = plz_grouped.size().reset_index(name='count')
|
||||
|
||||
# Collect unique attributes for each PLZ
|
||||
# Collect unique attributes for each PLZ based on tooltip_config
|
||||
attribute_summaries = {}
|
||||
if request.tooltip_config:
|
||||
visible_columns = [col.name for col in request.tooltip_config if col.visible]
|
||||
ordered_columns = [col.name for col in request.tooltip_config]
|
||||
else:
|
||||
# Fallback if no config is provided
|
||||
visible_columns = [col for col in filtered_df.columns if col != plz_column_name]
|
||||
ordered_columns = visible_columns
|
||||
|
||||
for plz_val, group in plz_grouped:
|
||||
summary = {}
|
||||
for col in filtered_df.columns:
|
||||
if col != plz_column_name and col != 'lat' and col != 'lon': # Exclude lat/lon if they somehow exist
|
||||
unique_attrs = group[col].unique().tolist()
|
||||
# Limit to top 3 unique values for readability
|
||||
summary[col] = unique_attrs[:3]
|
||||
for col_name in visible_columns:
|
||||
if col_name in group:
|
||||
unique_attrs = group[col_name].unique().tolist()
|
||||
summary[col_name] = unique_attrs[:3]
|
||||
attribute_summaries[plz_val] = summary
|
||||
|
||||
# Convert summaries to a DataFrame for merging
|
||||
@@ -199,16 +212,21 @@ async def get_heatmap_data(request: FilterRequest):
|
||||
# For each record, pick out the attributes that are not 'plz', 'lat', 'lon', 'count'
|
||||
final_heatmap_data = []
|
||||
for record in heatmap_data:
|
||||
attrs = {k: v for k, v in record.items() if k not in ['plz', 'lat', 'lon', 'count']}
|
||||
# Order the attributes based on tooltip_config
|
||||
ordered_attrs = {
|
||||
col_name: record.get(col_name)
|
||||
for col_name in ordered_columns
|
||||
if col_name in record and record.get(col_name) is not None
|
||||
}
|
||||
final_heatmap_data.append({
|
||||
"plz": record['plz'],
|
||||
"lat": record['lat'],
|
||||
"lon": record['lon'],
|
||||
"count": record['count'],
|
||||
"attributes_summary": attrs
|
||||
"attributes_summary": ordered_attrs
|
||||
})
|
||||
|
||||
print(f"Generated heatmap data with {len(final_heatmap_data)} PLZ points.")
|
||||
print(f"Generated heatmap data with {len(final_heatmap_data)} PLZ points, respecting tooltip config.")
|
||||
return final_heatmap_data
|
||||
|
||||
except Exception as e:
|
||||
|
||||
61
heatmap-tool/frontend/package-lock.json
generated
61
heatmap-tool/frontend/package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"axios": "^1.13.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
@@ -316,6 +318,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
@@ -3405,6 +3460,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"axios": "^1.13.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import 'react-leaflet-cluster/dist/assets/MarkerCluster.css';
|
||||
@@ -7,6 +7,8 @@ import FileUpload from './components/FileUpload';
|
||||
import FilterPanel from './components/FilterPanel';
|
||||
import MapDisplay from './components/MapDisplay';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import PlzSelector from './components/PlzSelector';
|
||||
import TooltipManager, { TooltipColumn } from './components/TooltipManager';
|
||||
|
||||
// Define types for our state
|
||||
export interface FilterOptions {
|
||||
@@ -33,6 +35,7 @@ function App() {
|
||||
|
||||
const [plzColumnNeeded, setPlzColumnNeeded] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
||||
const [tooltipColumns, setTooltipColumns] = useState<TooltipColumn[]>([]);
|
||||
|
||||
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
@@ -42,12 +45,17 @@ function App() {
|
||||
setPlzColumnNeeded(true);
|
||||
setFilters({});
|
||||
setHeatmapData([]);
|
||||
setTooltipColumns([]);
|
||||
} else {
|
||||
setPlzColumnNeeded(false);
|
||||
setFilters(response.filters);
|
||||
const newFilters = response.filters;
|
||||
setFilters(newFilters);
|
||||
// Initialize tooltip columns based on filters
|
||||
setTooltipColumns(Object.keys(newFilters).map(name => ({ id: name, name, visible: true })));
|
||||
setHeatmapData([]); // Clear previous heatmap data
|
||||
// Automatically fetch data with no filters on successful upload
|
||||
handleFilterChange({});
|
||||
// Pass initial tooltip config
|
||||
handleFilterChange({}, Object.keys(newFilters).map(name => ({ id: name, name, visible: true })));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,26 +78,37 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = async (selectedFilters: FilterOptions) => {
|
||||
const handleFilterChange = async (selectedFilters: FilterOptions, currentTooltipConfig: TooltipColumn[]) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.post('/api/heatmap', {
|
||||
filters: selectedFilters,
|
||||
tooltip_config: currentTooltipConfig, // Pass tooltip config to backend
|
||||
});
|
||||
setHeatmapData(response.data);
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
setError(`Failed to fetch heatmap data: ${error.response.data.detail || error.message}`);
|
||||
} else {
|
||||
setError(`Failed to fetch heatmap data: ${error.message}`);
|
||||
}
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
setError(`Failed to fetch heatmap data: ${error.response.data.detail || error.message}`);
|
||||
} else {
|
||||
setError(`Failed to fetch heatmap data: ${error.message}`);
|
||||
}
|
||||
setHeatmapData([]); // Clear data on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Need to re-fetch data when tooltip config changes
|
||||
useEffect(() => {
|
||||
if (Object.keys(filters).length > 0) {
|
||||
// This assumes you want to re-fetch with the *last applied* filters, which is complex to track.
|
||||
// For simplicity, we won't re-fetch automatically on tooltip change for now,
|
||||
// but the config will be applied on the *next* "Apply Filters" click.
|
||||
}
|
||||
}, [tooltipColumns]);
|
||||
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="App">
|
||||
@@ -113,9 +132,12 @@ function App() {
|
||||
<>
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterChange={(selectedFilters) => handleFilterChange(selectedFilters, tooltipColumns)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{tooltipColumns.length > 0 && (
|
||||
<TooltipManager columns={tooltipColumns} setColumns={setTooltipColumns} />
|
||||
)}
|
||||
<div className="map-controls" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #555' }}>
|
||||
<h3>Map Settings</h3>
|
||||
<div className="toggle-switch" style={{ marginBottom: '15px' }}>
|
||||
@@ -151,6 +173,7 @@ function App() {
|
||||
heatmapData={heatmapData}
|
||||
radiusMultiplier={radiusMultiplier}
|
||||
viewMode={viewMode}
|
||||
tooltipColumns={tooltipColumns}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -6,14 +6,16 @@ import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.heat';
|
||||
import type { HeatmapPoint, MapMode } from '../App';
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
import { TooltipColumn } from './TooltipManager';
|
||||
|
||||
interface MapDisplayProps {
|
||||
heatmapData: HeatmapPoint[];
|
||||
radiusMultiplier: number;
|
||||
viewMode: MapMode;
|
||||
tooltipColumns: TooltipColumn[];
|
||||
}
|
||||
|
||||
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier, viewMode }) => {
|
||||
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier, viewMode, tooltipColumns }) => {
|
||||
const germanyCenter: [number, number] = [51.1657, 10.4515];
|
||||
const maxCount = Math.max(...heatmapData.map(p => p.count), 1);
|
||||
|
||||
@@ -45,11 +47,17 @@ const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier,
|
||||
<Tooltip>
|
||||
PLZ: {point.plz} <br />
|
||||
Count: {point.count}
|
||||
{point.attributes_summary && Object.entries(point.attributes_summary).map(([attr, values]) => (
|
||||
<div key={attr}>
|
||||
<strong>{attr}:</strong> {values.join(', ')}
|
||||
</div>
|
||||
))}
|
||||
{tooltipColumns.map(col => {
|
||||
if (col.visible && point.attributes_summary && point.attributes_summary[col.name]) {
|
||||
const values = point.attributes_summary[col.name];
|
||||
return (
|
||||
<div key={col.id}>
|
||||
<strong>{col.name}:</strong> {Array.isArray(values) ? values.join(', ') : values}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user