refactor: enhance distance calculation and heatmap functionality; improve error handling and add interaction support

This commit is contained in:
vergiLgood1 2025-05-07 03:17:59 +07:00
parent 57fb1e4e46
commit 6bfc148821
4 changed files with 159 additions and 176 deletions

View File

@ -318,30 +318,39 @@ 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 supabase = createClient();
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,
try {
const { data, error } = await supabase.rpc(
'calculate_unit_incident_distances',
{
p_unit_id: p_unit_id || 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 [];
}
);
}

View File

@ -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>

View File

@ -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

View File

@ -64,154 +64,93 @@ export class CrimeIncidentsSeeder {
});
}
/**
* Generates well-distributed points within a district's area with geographical constraints
* @param centerLat - The center latitude of the district
* @param centerLng - The center longitude of the district
* @param landArea - Land area in square km
* @param numPoints - Number of points to generate
* @param districtId - ID of the district for special handling
* @param districtName - Name of the district for constraints
* @returns Array of {latitude, longitude, radius} points
*/
private generateDistributedPoints(
centerLat: number,
centerLng: number,
landArea: number,
numPoints: number,
districtId: string,
districtName: string
): Array<{ latitude: number; longitude: number; radius: number }> {
const points = [];
/**
* Generates well-distributed points within a district's area with geographical constraints
* @param centerLat - The center latitude of the district
* @param centerLng - The center longitude of the district
* @param landArea - Land area in square km
* @param numPoints - Number of points to generate
* @param districtId - ID of the district for special handling
* @param districtName - Name of the district for constraints
* @returns Array of {latitude, longitude, radius} points
*/
private generateDistributedPoints(
centerLat: number,
centerLng: number,
landArea: number,
numPoints: number,
districtId: string,
districtName: string
): 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));
// Create a grid-based distribution for better coverage
const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5));
// Define district bounds with geographical constraints
// Standard calculation for general districts
let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111;
// 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;
// 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;
}
// 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
private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) {
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
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