refactor: enhance distance calculation and heatmap functionality; improve error handling and add interaction support
This commit is contained in:
parent
57fb1e4e46
commit
6bfc148821
|
@ -318,30 +318,39 @@ export async function getCrimeByYearAndMonth(
|
||||||
* @returns Array of distance calculations between units and incidents
|
* @returns Array of distance calculations between units and incidents
|
||||||
*/
|
*/
|
||||||
export async function calculateDistances(
|
export async function calculateDistances(
|
||||||
unitId?: string,
|
p_unit_id?: string,
|
||||||
districtId?: string
|
p_district_id?: string
|
||||||
): Promise<IDistanceResult[]> {
|
): Promise<IDistanceResult[]> {
|
||||||
const supabase = createClient();
|
const instrumentationService = getInjection('IInstrumentationService');
|
||||||
|
return await instrumentationService.instrumentServerAction(
|
||||||
|
'Calculate Distances',
|
||||||
|
{ recordResponse: true },
|
||||||
|
async () => {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.rpc(
|
const { data, error } = await supabase.rpc(
|
||||||
'calculate_unit_incident_distances',
|
'calculate_unit_incident_distances',
|
||||||
{
|
{
|
||||||
unit_id: unitId || null,
|
p_unit_id: p_unit_id || null,
|
||||||
district_id: districtId || null,
|
p_district_id: p_district_id || null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error calculating distances:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as IDistanceResult[] || [];
|
||||||
|
} catch (error) {
|
||||||
|
const crashReporterService = getInjection('ICrashReporterService');
|
||||||
|
crashReporterService.report(error);
|
||||||
|
console.error('Failed to calculate distances:', error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error calculating distances:', error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to calculate distances:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { Button } from "@/app/_components/ui/button"
|
import { Button } from "@/app/_components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||||
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Map, Shield, Users } from "lucide-react"
|
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Thermometer, Shield, Users } from "lucide-react"
|
||||||
import { ITooltips } from "./tooltips"
|
import { ITooltips } from "./tooltips"
|
||||||
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
|
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||||
// Define the primary crime data controls
|
// Define the primary crime data controls
|
||||||
const crimeTooltips = [
|
const crimeTooltips = [
|
||||||
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
||||||
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" },
|
{ id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" },
|
||||||
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
|
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
|
||||||
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
|
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
|
||||||
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
|
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
|
||||||
|
@ -23,6 +23,15 @@ interface CrimeTooltipsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
|
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
|
||||||
|
const handleControlClick = (controlId: ITooltips) => {
|
||||||
|
console.log("Clicked control:", controlId);
|
||||||
|
// Force the value to be set when clicking
|
||||||
|
if (onControlChange) {
|
||||||
|
onControlChange(controlId);
|
||||||
|
console.log("Control changed to:", controlId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
@ -36,7 +45,7 @@ export default function CrimeTooltips({ activeControl, onControlChange }: CrimeT
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onControlChange?.(control.id)}
|
onClick={() => handleControlClick(control.id)}
|
||||||
>
|
>
|
||||||
{control.icon}
|
{control.icon}
|
||||||
<span className="sr-only">{control.label}</span>
|
<span className="sr-only">{control.label}</span>
|
||||||
|
|
|
@ -8,14 +8,20 @@ interface HeatmapLayerProps {
|
||||||
month: string;
|
month: string;
|
||||||
filterCategory: string | "all";
|
filterCategory: string | "all";
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
useAllData?: boolean; // Add prop to indicate if we should use all data
|
useAllData?: boolean;
|
||||||
|
enableInteractions?: boolean; // Add new prop
|
||||||
|
setFocusedDistrictId?: (id: string | null, isMarkerClick?: boolean) => void; // Add new prop
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeatmapLayer({
|
export default function HeatmapLayer({
|
||||||
crimes,
|
crimes,
|
||||||
visible = true,
|
visible = true,
|
||||||
useAllData = false,
|
useAllData = false,
|
||||||
filterCategory
|
filterCategory,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
enableInteractions = true,
|
||||||
|
setFocusedDistrictId
|
||||||
}: HeatmapLayerProps) {
|
}: HeatmapLayerProps) {
|
||||||
// Convert crime data to GeoJSON format for the heatmap
|
// Convert crime data to GeoJSON format for the heatmap
|
||||||
const heatmapData = useMemo(() => {
|
const heatmapData = useMemo(() => {
|
||||||
|
@ -33,6 +39,17 @@ export default function HeatmapLayer({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by year and month if not using all data
|
||||||
|
if (!useAllData && year && month) {
|
||||||
|
const incidentDate = new Date(incident.timestamp);
|
||||||
|
const incidentYear = incidentDate.getFullYear().toString();
|
||||||
|
const incidentMonth = (incidentDate.getMonth() + 1).toString();
|
||||||
|
|
||||||
|
if (incidentYear !== year || incidentMonth !== month) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(incident => ({
|
.map(incident => ({
|
||||||
|
@ -42,6 +59,7 @@ export default function HeatmapLayer({
|
||||||
category: incident.crime_categories?.name || "Unknown",
|
category: incident.crime_categories?.name || "Unknown",
|
||||||
intensity: 1, // Base intensity value
|
intensity: 1, // Base intensity value
|
||||||
timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null,
|
timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null,
|
||||||
|
districtId: crime.district_id // Add district ID for potential interactions
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point" as const,
|
type: "Point" as const,
|
||||||
|
@ -54,10 +72,14 @@ export default function HeatmapLayer({
|
||||||
type: "FeatureCollection" as const,
|
type: "FeatureCollection" as const,
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
}, [crimes, filterCategory]);
|
}, [crimes, filterCategory, useAllData, year, month]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
// The heatmap layer doesn't generally support direct interactions like clicks,
|
||||||
|
// but we're including the props to maintain consistency with other layers
|
||||||
|
// and to support future interaction patterns if needed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Source id="crime-heatmap-data" type="geojson" data={heatmapData}>
|
<Source id="crime-heatmap-data" type="geojson" data={heatmapData}>
|
||||||
<Layer
|
<Layer
|
||||||
|
@ -106,4 +128,4 @@ export default function HeatmapLayer({
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -64,154 +64,93 @@ export class CrimeIncidentsSeeder {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates well-distributed points within a district's area with geographical constraints
|
* Generates well-distributed points within a district's area with geographical constraints
|
||||||
* @param centerLat - The center latitude of the district
|
* @param centerLat - The center latitude of the district
|
||||||
* @param centerLng - The center longitude of the district
|
* @param centerLng - The center longitude of the district
|
||||||
* @param landArea - Land area in square km
|
* @param landArea - Land area in square km
|
||||||
* @param numPoints - Number of points to generate
|
* @param numPoints - Number of points to generate
|
||||||
* @param districtId - ID of the district for special handling
|
* @param districtId - ID of the district for special handling
|
||||||
* @param districtName - Name of the district for constraints
|
* @param districtName - Name of the district for constraints
|
||||||
* @returns Array of {latitude, longitude, radius} points
|
* @returns Array of {latitude, longitude, radius} points
|
||||||
*/
|
*/
|
||||||
private generateDistributedPoints(
|
private generateDistributedPoints(
|
||||||
centerLat: number,
|
centerLat: number,
|
||||||
centerLng: number,
|
centerLng: number,
|
||||||
landArea: number,
|
landArea: number,
|
||||||
numPoints: number,
|
numPoints: number,
|
||||||
districtId: string,
|
districtId: string,
|
||||||
districtName: string
|
districtName: string
|
||||||
): Array<{ latitude: number; longitude: number; radius: number }> {
|
): Array<{ latitude: number; longitude: number; radius: number }> {
|
||||||
const points = [];
|
const points = [];
|
||||||
|
const districtNameLower = districtName.toLowerCase();
|
||||||
|
|
||||||
// Calculate a reasonable radius based on land area
|
// Special centers for specific districts
|
||||||
const areaFactor = Math.sqrt(landArea) / 100;
|
if (districtNameLower === 'puger') {
|
||||||
const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03));
|
centerLat = -8.28386264271547;
|
||||||
|
centerLng = 113.48075672799605;
|
||||||
// Create a grid-based distribution for better coverage
|
// } else if (districtNameLower === 'tempurejo') {
|
||||||
const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5));
|
// centerLat = -8.276417231420199;
|
||||||
|
// centerLng = 113.69726469589183;
|
||||||
// Define district bounds with geographical constraints
|
} else if (districtNameLower === 'mumbulsari') {
|
||||||
// Standard calculation for general districts
|
centerLat = -8.25042152635794;
|
||||||
let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111;
|
centerLng = 113.74209367242942;
|
||||||
|
|
||||||
// District-specific adjustments to avoid generating points in the ocean
|
|
||||||
const southernCoastalDistricts = [
|
|
||||||
'puger',
|
|
||||||
'tempurejo',
|
|
||||||
'ambulu',
|
|
||||||
'gumukmas',
|
|
||||||
'kencong',
|
|
||||||
'wuluhan',
|
|
||||||
'kencong',
|
|
||||||
];
|
|
||||||
const isCoastalDistrict = southernCoastalDistricts.some((district) =>
|
|
||||||
districtName.toLowerCase().includes(district)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Default bounds
|
|
||||||
let bounds = {
|
|
||||||
minLat: centerLat - estimatedDistrictRadius,
|
|
||||||
maxLat: centerLat + estimatedDistrictRadius,
|
|
||||||
minLng: centerLng - estimatedDistrictRadius,
|
|
||||||
maxLng: centerLng + estimatedDistrictRadius,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply special constraints for coastal districts
|
|
||||||
if (isCoastalDistrict) {
|
|
||||||
// Shift points northward for southern coastal districts to avoid ocean
|
|
||||||
if (
|
|
||||||
districtName.toLowerCase().includes('puger') ||
|
|
||||||
districtName.toLowerCase().includes('tempurejo')
|
|
||||||
) {
|
|
||||||
// For Puger and Tempurejo, shift more aggressively northward
|
|
||||||
bounds = {
|
|
||||||
minLat: centerLat, // Don't go south of the center
|
|
||||||
maxLat: centerLat + estimatedDistrictRadius * 1.5, // Extend more to the north
|
|
||||||
minLng: centerLng - estimatedDistrictRadius * 0.8,
|
|
||||||
maxLng: centerLng + estimatedDistrictRadius * 0.8,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// For other coastal districts, shift moderately northward
|
|
||||||
bounds = {
|
|
||||||
minLat: centerLat - estimatedDistrictRadius * 0.5, // Less southward
|
|
||||||
maxLat: centerLat + estimatedDistrictRadius * 1.2, // More northward
|
|
||||||
minLng: centerLng - estimatedDistrictRadius,
|
|
||||||
maxLng: centerLng + estimatedDistrictRadius,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const latStep = (bounds.maxLat - bounds.minLat) / gridSize;
|
|
||||||
const lngStep = (bounds.maxLng - bounds.minLng) / gridSize;
|
|
||||||
|
|
||||||
// Generate points in each grid cell with some randomness
|
|
||||||
let totalPoints = 0;
|
|
||||||
for (let i = 0; i < gridSize && totalPoints < numPoints; i++) {
|
|
||||||
for (let j = 0; j < gridSize && totalPoints < numPoints; j++) {
|
|
||||||
// Base position within the grid cell with randomness
|
|
||||||
const cellLat =
|
|
||||||
bounds.minLat + (i + 0.2 + Math.random() * 0.6) * latStep;
|
|
||||||
const cellLng =
|
|
||||||
bounds.minLng + (j + 0.2 + Math.random() * 0.6) * lngStep;
|
|
||||||
|
|
||||||
// Distance from center (for radius reference)
|
|
||||||
const latDiff = cellLat - centerLat;
|
|
||||||
const lngDiff = cellLng - centerLng;
|
|
||||||
const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
|
|
||||||
|
|
||||||
// Add some randomness to avoid perfect grid pattern
|
|
||||||
const jitter = baseRadius * 0.2;
|
|
||||||
const latitude = cellLat + (Math.random() * 2 - 1) * jitter;
|
|
||||||
const longitude = cellLng + (Math.random() * 2 - 1) * jitter;
|
|
||||||
|
|
||||||
// Ensure the point is within district boundaries
|
|
||||||
// Simple check to ensure points don't stray too far from center
|
|
||||||
if (distance <= estimatedDistrictRadius * 1.2) {
|
|
||||||
points.push({
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
radius: distance * 111000, // Convert to meters (approx)
|
|
||||||
});
|
|
||||||
|
|
||||||
totalPoints++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still need more points, add some with tighter constraints
|
|
||||||
while (points.length < numPoints) {
|
|
||||||
// For coastal districts, use more controlled distribution
|
|
||||||
let latitude, longitude;
|
|
||||||
|
|
||||||
if (isCoastalDistrict) {
|
|
||||||
// Generate points with northward bias for coastal districts
|
|
||||||
const northBias = Math.random() * 0.7 + 0.3; // 0.3 to 1.0, favoring north
|
|
||||||
latitude = centerLat + northBias * estimatedDistrictRadius * 0.8;
|
|
||||||
longitude =
|
|
||||||
centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
|
|
||||||
} else {
|
|
||||||
// Standard distribution for non-coastal districts
|
|
||||||
latitude =
|
|
||||||
centerLat + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
|
|
||||||
longitude =
|
|
||||||
centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latDiff = latitude - centerLat;
|
|
||||||
const lngDiff = longitude - centerLng;
|
|
||||||
const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
|
|
||||||
|
|
||||||
points.push({
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
radius: distance * 111000, // Convert to meters (approx)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return points;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate radius proportional to sqrt(landArea) but with better constraints
|
||||||
|
let scalingFactor = 0.3;
|
||||||
|
|
||||||
|
// Special radius handling for specific districts
|
||||||
|
if (districtNameLower === 'mumbulsari') {
|
||||||
|
scalingFactor = 0.1; // Tighter scaling for Mumbulsari
|
||||||
|
} else if (landArea > 300) {
|
||||||
|
scalingFactor = 0.25;
|
||||||
|
} else if (landArea < 50) {
|
||||||
|
scalingFactor = 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radiusKm = Math.sqrt(landArea) * scalingFactor;
|
||||||
|
const radiusDeg = radiusKm / 111;
|
||||||
|
|
||||||
|
// Generate points in a circle around the center
|
||||||
|
for (let i = 0; i < numPoints; i++) {
|
||||||
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
|
||||||
|
// Adjust distance factor for Mumbulsari and Tempurejo
|
||||||
|
let distanceFactor = Math.pow(Math.random(), 1.5);
|
||||||
|
if (districtNameLower === 'mumbulsari') {
|
||||||
|
distanceFactor = Math.pow(Math.random(), 2); // Concentrate points closer to the center
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = distanceFactor * radiusDeg;
|
||||||
|
|
||||||
|
let latitude = centerLat + distance * Math.cos(angle);
|
||||||
|
let longitude = centerLng + distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
|
||||||
|
|
||||||
|
// Bias points for Tempurejo towards the south
|
||||||
|
// if (districtNameLower === 'tempurejo') {
|
||||||
|
// latitude -= Math.abs(distance * 0.2); // Shift points slightly southward
|
||||||
|
// }
|
||||||
|
|
||||||
|
let pointRadius = distance * 111000;
|
||||||
|
|
||||||
|
if (districtNameLower === 'mumbulsari') {
|
||||||
|
pointRadius = Math.min(pointRadius, 5000);
|
||||||
|
} else {
|
||||||
|
const maxRadiusMeters = Math.min(10000, radiusKm * 500);
|
||||||
|
pointRadius = Math.min(pointRadius, maxRadiusMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radius: pointRadius,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper for chunked insertion
|
// Helper for chunked insertion
|
||||||
private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) {
|
private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) {
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
|
@ -340,11 +279,15 @@ export class CrimeIncidentsSeeder {
|
||||||
// Use the actual number of crimes instead of a random count
|
// Use the actual number of crimes instead of a random count
|
||||||
const numLocations = crime.number_of_crime;
|
const numLocations = crime.number_of_crime;
|
||||||
|
|
||||||
// Update the call to the function in createIncidentsForCrime method:
|
// Gunakan hanya geographic sebagai center
|
||||||
|
const centerLat = geo.latitude;
|
||||||
|
const centerLng = geo.longitude;
|
||||||
|
const landArea = geo.land_area ?? 100;
|
||||||
|
|
||||||
const locationPool = this.generateDistributedPoints(
|
const locationPool = this.generateDistributedPoints(
|
||||||
geo.latitude,
|
centerLat,
|
||||||
geo.longitude,
|
centerLng,
|
||||||
geo.land_area || 100, // Default to 100 km² if not available
|
landArea,
|
||||||
numLocations,
|
numLocations,
|
||||||
district.id,
|
district.id,
|
||||||
district.name
|
district.name
|
||||||
|
|
Loading…
Reference in New Issue