feat([2fd88f42]): add heatmap view with toggle switch
This commit is contained in:
29
heatmap-tool/frontend/package-lock.json
generated
29
heatmap-tool/frontend/package-lock.json
generated
@@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0"
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-heatmap-layer-v3": "^3.0.3-beta-1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -2848,6 +2850,11 @@
|
|||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet.heat": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -3171,6 +3178,20 @@
|
|||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet-heatmap-layer-v3": {
|
||||||
|
"version": "3.0.3-beta-1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet-heatmap-layer-v3/-/react-leaflet-heatmap-layer-v3-3.0.3-beta-1.tgz",
|
||||||
|
"integrity": "sha512-oNv6ul6JHxMIuBXqaGc5P/VM/P6bpiUYAeg1CMCmyeYxWkHfl2YiT4m8VdaJC4TjSxVoi/Ry2ChRTFjF5UsPsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"simpleheat": "^0.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.0.0",
|
||||||
|
"react": "^17.0.0",
|
||||||
|
"react-leaflet": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -3275,6 +3296,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simpleheat": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/simpleheat/-/simpleheat-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-tdg3I1NvzMdPKscWBrHbF0LBf+VWuBBazzGUPtFJjG5Q12VQX6gPY8jLy9hx5CbgAIVc5nfnePwJvAYK6z1rAA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0"
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-heatmap-layer-v3": "^3.0.3-beta-1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ export interface HeatmapPoint {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MapMode = 'points' | 'heatmap';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [filters, setFilters] = useState<FilterOptions>({});
|
const [filters, setFilters] = useState<FilterOptions>({});
|
||||||
const [heatmapData, setHeatmapData] = useState<HeatmapPoint[]>([]);
|
const [heatmapData, setHeatmapData] = useState<HeatmapPoint[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [radiusMultiplier, setRadiusMultiplier] = useState(1);
|
const [radiusMultiplier, setRadiusMultiplier] = useState(1);
|
||||||
|
const [viewMode, setViewMode] = useState<MapMode>('points');
|
||||||
|
|
||||||
const handleUploadSuccess = (newFilters: FilterOptions) => {
|
const handleUploadSuccess = (newFilters: FilterOptions) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
@@ -73,6 +76,16 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<div className="map-controls" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #555' }}>
|
<div className="map-controls" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #555' }}>
|
||||||
<h3>Map Settings</h3>
|
<h3>Map Settings</h3>
|
||||||
|
<div className="toggle-switch" style={{ marginBottom: '15px' }}>
|
||||||
|
<label>
|
||||||
|
<input type="radio" value="points" checked={viewMode === 'points'} onChange={() => setViewMode('points')} />
|
||||||
|
Points
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" value="heatmap" checked={viewMode === 'heatmap'} onChange={() => setViewMode('heatmap')} />
|
||||||
|
Heatmap
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label htmlFor="radius-slider">Marker Size: {radiusMultiplier.toFixed(1)}x</label>
|
<label htmlFor="radius-slider">Marker Size: {radiusMultiplier.toFixed(1)}x</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -83,13 +96,18 @@ function App() {
|
|||||||
value={radiusMultiplier}
|
value={radiusMultiplier}
|
||||||
onChange={(e) => setRadiusMultiplier(parseFloat(e.target.value))}
|
onChange={(e) => setRadiusMultiplier(parseFloat(e.target.value))}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
disabled={viewMode === 'heatmap'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="map-container">
|
<div className="map-container">
|
||||||
{isLoading && <p>Loading map data...</p>}
|
{isLoading && <p>Loading map data...</p>}
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<MapDisplay heatmapData={heatmapData} radiusMultiplier={radiusMultiplier} />
|
<MapDisplay
|
||||||
|
heatmapData={heatmapData}
|
||||||
|
radiusMultiplier={radiusMultiplier}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,81 @@
|
|||||||
// src/components/MapDisplay.tsx
|
// src/components/MapDisplay.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet';
|
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet';
|
||||||
|
import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { HeatmapPoint } from '../App';
|
import 'leaflet.heat';
|
||||||
|
import type { HeatmapPoint, MapMode } from '../App';
|
||||||
|
|
||||||
interface MapDisplayProps {
|
interface MapDisplayProps {
|
||||||
heatmapData: HeatmapPoint[];
|
heatmapData: HeatmapPoint[];
|
||||||
radiusMultiplier: number;
|
radiusMultiplier: number;
|
||||||
|
viewMode: MapMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier }) => {
|
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier, viewMode }) => {
|
||||||
const germanyCenter: [number, number] = [51.1657, 10.4515];
|
const germanyCenter: [number, number] = [51.1657, 10.4515];
|
||||||
|
const maxCount = Math.max(...heatmapData.map(p => p.count), 1);
|
||||||
|
|
||||||
// Simple scaling function for marker radius, now with a multiplier
|
|
||||||
const calculateRadius = (count: number) => {
|
const calculateRadius = (count: number) => {
|
||||||
// Ensure a base radius so even single points are visible
|
|
||||||
// The multiplier is applied to the dynamic part of the radius
|
|
||||||
return 3 + Math.log(count + 1) * 5 * radiusMultiplier;
|
return 3 + Math.log(count + 1) * 5 * radiusMultiplier;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple color scaling function
|
const getColor = (count: number) => {
|
||||||
const getColor = (count: number, maxCount: number) => {
|
|
||||||
const ratio = count / maxCount;
|
const ratio = count / maxCount;
|
||||||
if (ratio > 0.8) return '#d73027'; // Red
|
if (ratio > 0.8) return '#d73027'; // Red
|
||||||
if (ratio > 0.5) return '#fdae61'; // Orange
|
if (ratio > 0.5) return '#fdae61'; // Orange
|
||||||
if (ratio > 0.2) return '#fee08b'; // Yellow
|
if (ratio > 0.2) return '#fee08b'; // Yellow
|
||||||
return '#66bd63'; // Green
|
return '#66bd63'; // Green
|
||||||
}
|
};
|
||||||
|
|
||||||
const maxCount = Math.max(...heatmapData.map(p => p.count), 1);
|
const renderPoints = () => (
|
||||||
|
heatmapData.map((point, idx) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={idx}
|
||||||
|
center={[point.lat, point.lon]}
|
||||||
|
radius={calculateRadius(point.count)}
|
||||||
|
pathOptions={{
|
||||||
|
color: getColor(point.count),
|
||||||
|
fillColor: getColor(point.count),
|
||||||
|
fillOpacity: 0.7
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
PLZ: {point.plz} <br />
|
||||||
|
Count: {point.count}
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHeatmap = () => (
|
||||||
|
<HeatmapLayer
|
||||||
|
points={heatmapData}
|
||||||
|
longitudeExtractor={(p: HeatmapPoint) => p.lon}
|
||||||
|
latitudeExtractor={(p: HeatmapPoint) => p.lat}
|
||||||
|
intensityExtractor={(p: HeatmapPoint) => p.count}
|
||||||
|
radius={25}
|
||||||
|
blur={20}
|
||||||
|
max={maxCount * 0.1} // Adjust max intensity for better visualization
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (heatmapData.length === 0) {
|
if (heatmapData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', paddingTop: '50px' }}>
|
<div style={{ textAlign: 'center', paddingTop: '50px' }}>
|
||||||
<p>No data to display on the map.</p>
|
<p>No data to display on the map.</p>
|
||||||
<p>Upload a file and apply filters to see the heatmap.</p>
|
<p>Upload a file and apply filters to see the heatmap.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer center={germanyCenter} zoom={6} style={{ height: '100%', width: '100%' }}>
|
<MapContainer key={viewMode} center={germanyCenter} zoom={6} style={{ height: '100%', width: '100%' }}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
/>
|
/>
|
||||||
{heatmapData.map((point, idx) => (
|
{viewMode === 'points' ? renderPoints() : renderHeatmap()}
|
||||||
<CircleMarker
|
|
||||||
key={idx}
|
|
||||||
center={[point.lat, point.lon]}
|
|
||||||
radius={calculateRadius(point.count)}
|
|
||||||
pathOptions={{
|
|
||||||
color: getColor(point.count, maxCount),
|
|
||||||
fillColor: getColor(point.count, maxCount),
|
|
||||||
fillOpacity: 0.7
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
PLZ: {point.plz} <br />
|
|
||||||
Count: {point.count}
|
|
||||||
</Tooltip>
|
|
||||||
</CircleMarker>
|
|
||||||
))}
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user