refactor: enhance distance calculation and heatmap functionality; improve error handling and add interaction support
This commit is contained in:
parent
57fb1e4e46
commit
6bfc148821
|
@ -318,17 +318,22 @@ export async function getCrimeByYearAndMonth(
|
|||
* @returns Array of distance calculations between units and incidents
|
||||
*/
|
||||
export async function calculateDistances(
|
||||
unitId?: string,
|
||||
districtId?: string
|
||||
p_unit_id?: string,
|
||||
p_district_id?: string
|
||||
): Promise<IDistanceResult[]> {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'Calculate Distances',
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc(
|
||||
'calculate_unit_incident_distances',
|
||||
{
|
||||
unit_id: unitId || null,
|
||||
district_id: districtId || null,
|
||||
p_unit_id: p_unit_id || null,
|
||||
p_district_id: p_district_id || null,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -337,11 +342,15 @@ export async function calculateDistances(
|
|||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
return data as IDistanceResult[] || [];
|
||||
} catch (error) {
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
crashReporterService.report(error);
|
||||
console.error('Failed to calculate distances:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
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 { 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
|
||||
const crimeTooltips = [
|
||||
{ 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: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
|
||||
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
|
||||
|
@ -23,6 +23,15 @@ interface 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 (
|
||||
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
|
@ -36,7 +45,7 @@ export default function CrimeTooltips({ activeControl, onControlChange }: CrimeT
|
|||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
}`}
|
||||
onClick={() => onControlChange?.(control.id)}
|
||||
onClick={() => handleControlClick(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
|
|
|
@ -8,14 +8,20 @@ interface HeatmapLayerProps {
|
|||
month: string;
|
||||
filterCategory: string | "all";
|
||||
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({
|
||||
crimes,
|
||||
visible = true,
|
||||
useAllData = false,
|
||||
filterCategory
|
||||
filterCategory,
|
||||
year,
|
||||
month,
|
||||
enableInteractions = true,
|
||||
setFocusedDistrictId
|
||||
}: HeatmapLayerProps) {
|
||||
// Convert crime data to GeoJSON format for the heatmap
|
||||
const heatmapData = useMemo(() => {
|
||||
|
@ -33,6 +39,17 @@ export default function HeatmapLayer({
|
|||
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;
|
||||
})
|
||||
.map(incident => ({
|
||||
|
@ -42,6 +59,7 @@ export default function HeatmapLayer({
|
|||
category: incident.crime_categories?.name || "Unknown",
|
||||
intensity: 1, // Base intensity value
|
||||
timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null,
|
||||
districtId: crime.district_id // Add district ID for potential interactions
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
|
@ -54,10 +72,14 @@ export default function HeatmapLayer({
|
|||
type: "FeatureCollection" as const,
|
||||
features,
|
||||
};
|
||||
}, [crimes, filterCategory]);
|
||||
}, [crimes, filterCategory, useAllData, year, month]);
|
||||
|
||||
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 (
|
||||
<Source id="crime-heatmap-data" type="geojson" data={heatmapData}>
|
||||
<Layer
|
||||
|
|
|
@ -74,143 +74,82 @@ export class CrimeIncidentsSeeder {
|
|||
* @param districtName - Name of the district for constraints
|
||||
* @returns Array of {latitude, longitude, radius} points
|
||||
*/
|
||||
private generateDistributedPoints(
|
||||
private generateDistributedPoints(
|
||||
centerLat: number,
|
||||
centerLng: number,
|
||||
landArea: number,
|
||||
numPoints: number,
|
||||
districtId: string,
|
||||
districtName: string
|
||||
): Array<{ latitude: number; longitude: number; radius: number }> {
|
||||
): Array<{ latitude: number; longitude: number; radius: number }> {
|
||||
const points = [];
|
||||
const districtNameLower = districtName.toLowerCase();
|
||||
|
||||
// Calculate a reasonable radius based on land area
|
||||
const areaFactor = Math.sqrt(landArea) / 100;
|
||||
const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03));
|
||||
// Special centers for specific districts
|
||||
if (districtNameLower === 'puger') {
|
||||
centerLat = -8.28386264271547;
|
||||
centerLng = 113.48075672799605;
|
||||
// } else if (districtNameLower === 'tempurejo') {
|
||||
// centerLat = -8.276417231420199;
|
||||
// centerLng = 113.69726469589183;
|
||||
} else if (districtNameLower === 'mumbulsari') {
|
||||
centerLat = -8.25042152635794;
|
||||
centerLng = 113.74209367242942;
|
||||
}
|
||||
|
||||
// Create a grid-based distribution for better coverage
|
||||
const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5));
|
||||
// Calculate radius proportional to sqrt(landArea) but with better constraints
|
||||
let scalingFactor = 0.3;
|
||||
|
||||
// Define district bounds with geographical constraints
|
||||
// Standard calculation for general districts
|
||||
let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
const radiusKm = Math.sqrt(landArea) * scalingFactor;
|
||||
const radiusDeg = radiusKm / 111;
|
||||
|
||||
// Default bounds
|
||||
let bounds = {
|
||||
minLat: centerLat - estimatedDistrictRadius,
|
||||
maxLat: centerLat + estimatedDistrictRadius,
|
||||
minLng: centerLng - estimatedDistrictRadius,
|
||||
maxLng: centerLng + estimatedDistrictRadius,
|
||||
};
|
||||
// Generate points in a circle around the center
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
|
||||
// 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,
|
||||
};
|
||||
// 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 {
|
||||
// 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 maxRadiusMeters = Math.min(10000, radiusKm * 500);
|
||||
pointRadius = Math.min(pointRadius, maxRadiusMeters);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
radius: pointRadius,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for chunked insertion
|
||||
private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) {
|
||||
|
@ -340,11 +279,15 @@ export class CrimeIncidentsSeeder {
|
|||
// Use the actual number of crimes instead of a random count
|
||||
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(
|
||||
geo.latitude,
|
||||
geo.longitude,
|
||||
geo.land_area || 100, // Default to 100 km² if not available
|
||||
centerLat,
|
||||
centerLng,
|
||||
landArea,
|
||||
numLocations,
|
||||
district.id,
|
||||
district.name
|
||||
|
|
Loading…
Reference in New Issue