607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
import {
|
|
PrismaClient,
|
|
crime_rates,
|
|
crime_status,
|
|
crimes,
|
|
} from '@prisma/client';
|
|
import { generateId, generateIdWithDbCounter } from '../../app/_utils/common';
|
|
import { createClient } from '../../app/_utils/supabase/client';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import csv from 'csv-parser';
|
|
|
|
type ICreateLocations = {
|
|
id: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
district_id: string;
|
|
event_id: string;
|
|
address: string;
|
|
type: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
land_area: number;
|
|
location: string;
|
|
};
|
|
|
|
export class CrimeIncidentsSeeder {
|
|
private crimeMonthlyData: Map<
|
|
string,
|
|
{ number_of_crime: number; crime_cleared: number }
|
|
> = new Map();
|
|
|
|
constructor(
|
|
private prisma: PrismaClient,
|
|
private supabase = createClient()
|
|
) {}
|
|
|
|
private async loadCrimeMonthlyData(): Promise<void> {
|
|
const csvFilePath = path.resolve(
|
|
__dirname,
|
|
'../data/excels/crimes/crime_monthly.csv'
|
|
);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
fs.createReadStream(csvFilePath)
|
|
.pipe(csv())
|
|
.on('data', (row) => {
|
|
const key = `${row.district_id}-${row.month_num}-${row.year}`;
|
|
this.crimeMonthlyData.set(key, {
|
|
number_of_crime: parseInt(row.number_of_crime),
|
|
crime_cleared: parseInt(row.crime_cleared),
|
|
});
|
|
})
|
|
.on('end', () => {
|
|
console.log(
|
|
`Loaded ${this.crimeMonthlyData.size} crime monthly records.`
|
|
);
|
|
resolve();
|
|
})
|
|
.on('error', (error) => {
|
|
console.error('Error loading crime monthly data:', error);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 = [];
|
|
|
|
// 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;
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
console.log('🌱 Seeding crime incidents data...');
|
|
|
|
try {
|
|
// Load crime monthly data first
|
|
await this.loadCrimeMonthlyData();
|
|
|
|
// Check if crime incidents already exist
|
|
const existingIncidents = await this.prisma.crime_incidents.findFirst();
|
|
if (existingIncidents) {
|
|
console.log('Crime incidents data already exists, skipping import.');
|
|
return;
|
|
}
|
|
|
|
// Get all monthly crime records (exclude yearly summaries)
|
|
const crimeRecords = await this.prisma.crimes.findMany({
|
|
where: {
|
|
month: { not: null },
|
|
// Only process records with incidents (number_of_crime > 0)
|
|
number_of_crime: { gt: 0 },
|
|
},
|
|
orderBy: [{ district_id: 'asc' }, { year: 'asc' }, { month: 'asc' }],
|
|
});
|
|
|
|
console.log(
|
|
`Found ${crimeRecords.length} monthly crime records with incidents to process.`
|
|
);
|
|
|
|
// Get all crime categories for random assignment
|
|
const crimeCategories = await this.prisma.crime_categories.findMany();
|
|
if (crimeCategories.length === 0) {
|
|
console.error(
|
|
'No crime categories found, please seed crime categories first.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Process each crime record
|
|
let totalIncidentsCreated = 0;
|
|
for (const crimeRecord of crimeRecords) {
|
|
const incidents = await this.createIncidentsForCrime(
|
|
crimeRecord,
|
|
crimeCategories
|
|
);
|
|
totalIncidentsCreated += incidents.length;
|
|
|
|
// Log progress every 50 crime records
|
|
if (totalIncidentsCreated % 50 === 0) {
|
|
console.log(`Created ${totalIncidentsCreated} incidents so far...`);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`✅ Successfully created ${totalIncidentsCreated} crime incidents.`
|
|
);
|
|
} catch (error) {
|
|
console.error('❌ Error seeding crime incidents:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async createIncidentsForCrime(
|
|
crime: crimes,
|
|
categories: any[]
|
|
): Promise<any[]> {
|
|
const incidentsCreated = [];
|
|
|
|
// Get district information
|
|
const district = await this.prisma.districts.findUnique({
|
|
where: { id: crime.district_id },
|
|
include: { cities: true },
|
|
});
|
|
|
|
if (!district) {
|
|
console.error(`District ${crime.district_id} not found, skipping.`);
|
|
return [];
|
|
}
|
|
|
|
const geo = await this.prisma.geographics.findFirst({
|
|
where: {
|
|
district_id: district.id,
|
|
year: crime.year,
|
|
},
|
|
select: {
|
|
latitude: true,
|
|
longitude: true,
|
|
land_area: true,
|
|
},
|
|
});
|
|
|
|
if (!geo) {
|
|
console.error(
|
|
`Geographic data for district ${district.id} in year ${crime.year} not found, skipping.`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
// 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:
|
|
const locationPool = this.generateDistributedPoints(
|
|
geo.latitude,
|
|
geo.longitude,
|
|
geo.land_area || 100, // Default to 100 km² if not available
|
|
numLocations,
|
|
district.id,
|
|
district.name
|
|
);
|
|
|
|
// List of common street names in Jember with more variety
|
|
const jemberStreets = [
|
|
'Jalan Pahlawan',
|
|
'Jalan Merdeka',
|
|
'Jalan Cendrawasih',
|
|
'Jalan Srikandi',
|
|
'Jalan Sumbermujur',
|
|
'Jalan Taman Siswa',
|
|
'Jalan Pantai',
|
|
'Jalan Raya Sumberbaru',
|
|
'Jalan Abdurrahman Saleh',
|
|
'Jalan Mastrip',
|
|
'Jalan PB Sudirman',
|
|
'Jalan Kalimantan',
|
|
'Jalan Sumatra',
|
|
'Jalan Jawa',
|
|
'Jalan Gajah Mada',
|
|
'Jalan Letjen Suprapto',
|
|
'Jalan Hayam Wuruk',
|
|
'Jalan Trunojoyo',
|
|
'Jalan Imam Bonjol',
|
|
'Jalan Diponegoro',
|
|
'Jalan Ahmad Yani',
|
|
'Jalan Kartini',
|
|
'Jalan Gatot Subroto',
|
|
];
|
|
|
|
// More varied place types
|
|
const placeTypes = [
|
|
'Perumahan',
|
|
'Apartemen',
|
|
'Komplek',
|
|
'Pasar',
|
|
'Toko',
|
|
'Terminal',
|
|
'Stasiun',
|
|
'Kampus',
|
|
'Sekolah',
|
|
'Perkantoran',
|
|
'Pertokoan',
|
|
'Pusat Perbelanjaan',
|
|
'Taman',
|
|
'Alun-alun',
|
|
'Simpang',
|
|
'Pertigaan',
|
|
'Perempatan',
|
|
];
|
|
|
|
const user = await this.prisma.users.findFirst({
|
|
where: {
|
|
email: 'admin@gmail.com',
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
console.error(`User not found, skipping.`);
|
|
return [];
|
|
}
|
|
|
|
const event = await this.prisma.events.findFirst({
|
|
where: {
|
|
user_id: user.id,
|
|
},
|
|
});
|
|
|
|
if (!event) {
|
|
console.error(`Event not found, skipping.`);
|
|
return [];
|
|
}
|
|
|
|
// Get crime_cleared data from the loaded CSV
|
|
const key = `${crime.district_id}-${crime.month}-${crime.year}`;
|
|
const crimeMonthlyInfo = this.crimeMonthlyData.get(key);
|
|
|
|
// Default values if not found in CSV
|
|
let crimesCleared = 0;
|
|
|
|
if (crimeMonthlyInfo) {
|
|
crimesCleared = crimeMonthlyInfo.crime_cleared;
|
|
// Safety check to ensure crime_cleared doesn't exceed number_of_crime
|
|
if (crimesCleared > crime.number_of_crime) {
|
|
crimesCleared = crime.number_of_crime;
|
|
}
|
|
} else {
|
|
console.warn(
|
|
`No crime monthly data found for ${key}, using default values.`
|
|
);
|
|
}
|
|
|
|
// Create incidents based on the number_of_crime value
|
|
for (let i = 0; i < crime.number_of_crime; i++) {
|
|
// Select random category
|
|
const randomCategory =
|
|
categories[Math.floor(Math.random() * categories.length)];
|
|
|
|
// Calculate a date within the crime's month
|
|
const year = crime.year;
|
|
|
|
if (!year) {
|
|
console.error(
|
|
`Year is not defined for crime record ${crime.id}, skipping.`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const month = (crime.month as number) - 1; // JavaScript months are 0-indexed
|
|
const maxDay = new Date(year, month + 1, 0).getDate(); // Get last day of month
|
|
const day = Math.floor(Math.random() * maxDay) + 1;
|
|
const hour = Math.floor(Math.random() * 24);
|
|
const minute = Math.floor(Math.random() * 60);
|
|
|
|
const timestamp = new Date(year, month, day, hour, minute);
|
|
|
|
// Select a random location from our pool
|
|
const randomLocationIndex = Math.floor(
|
|
Math.random() * locationPool.length
|
|
);
|
|
const selectedLocation = locationPool[randomLocationIndex];
|
|
|
|
// Generate varied address details
|
|
const streetName =
|
|
jemberStreets[Math.floor(Math.random() * jemberStreets.length)];
|
|
const buildingNumber = Math.floor(Math.random() * 200) + 1;
|
|
const placeType =
|
|
placeTypes[Math.floor(Math.random() * placeTypes.length)];
|
|
|
|
// Create more varied addresses
|
|
let randomAddress;
|
|
const addressType = Math.floor(Math.random() * 3);
|
|
switch (addressType) {
|
|
case 0:
|
|
randomAddress = `${streetName} No. ${buildingNumber}, ${district.name}, Jember`;
|
|
break;
|
|
case 1:
|
|
randomAddress = `${placeType} ${district.name}, ${streetName}, Jember`;
|
|
break;
|
|
case 2:
|
|
randomAddress = `${streetName} Blok ${String.fromCharCode(65 + Math.floor(Math.random() * 26))}-${Math.floor(Math.random() * 20) + 1}, ${district.name}, Jember`;
|
|
break;
|
|
}
|
|
|
|
const locationData: Partial<ICreateLocations> = {
|
|
district_id: district.id,
|
|
event_id: event.id,
|
|
address: randomAddress,
|
|
type: 'crime',
|
|
latitude: selectedLocation.latitude,
|
|
longitude: selectedLocation.longitude,
|
|
location: `POINT(${selectedLocation.longitude} ${selectedLocation.latitude})`,
|
|
};
|
|
|
|
let { data: newLocation, error } = await this.supabase
|
|
.from('locations')
|
|
.insert([locationData])
|
|
.select();
|
|
|
|
if (error) {
|
|
console.error(
|
|
`Error inserting into locations for district ${district.name} (${crime.year}):`,
|
|
error
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const location = await this.prisma.locations.findFirst({
|
|
where: {
|
|
event_id: event.id,
|
|
district_id: district.id,
|
|
address: randomAddress,
|
|
},
|
|
select: {
|
|
id: true,
|
|
address: true,
|
|
},
|
|
});
|
|
|
|
if (!location) {
|
|
console.error(
|
|
`Location not found for district ${district.name} (${crime.year}), skipping.`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
// Generate a unique ID for the incident
|
|
const incidentId = await generateIdWithDbCounter(
|
|
'crime_incidents',
|
|
{
|
|
prefix: 'CI',
|
|
segments: {
|
|
codes: [district.city_id],
|
|
sequentialDigits: 4,
|
|
year,
|
|
},
|
|
format: '{prefix}-{codes}-{sequence}-{year}',
|
|
separator: '-',
|
|
uniquenessStrategy: 'counter',
|
|
},
|
|
/(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter
|
|
);
|
|
|
|
// Determine status based on crime_cleared
|
|
// If i < crimesCleared, this incident is resolved, otherwise unresolved
|
|
const status = i < crimesCleared ? 'resolved' : 'unresolved';
|
|
|
|
// More detailed location descriptions
|
|
const locs = [
|
|
`di daerah ${district.name}`,
|
|
`di sekitar ${district.name}`,
|
|
`di wilayah ${district.name}`,
|
|
`di jalan utama ${district.name}`,
|
|
`di perumahan ${district.name}`,
|
|
`di pasar ${district.name}`,
|
|
`di perbatasan ${district.name}`,
|
|
`di kawasan ${placeType.toLowerCase()} ${district.name}`,
|
|
`di persimpangan jalan ${streetName}`,
|
|
`di dekat ${placeType.toLowerCase()} ${district.name}`,
|
|
`di belakang ${placeType.toLowerCase()} ${district.name}`,
|
|
`di area ${streetName}`,
|
|
`di sekitar ${streetName} ${district.name}`,
|
|
`tidak jauh dari pusat ${district.name}`,
|
|
`di pinggiran ${district.name}`,
|
|
];
|
|
|
|
const randomLocation = locs[Math.floor(Math.random() * locs.length)];
|
|
|
|
const descriptions = [
|
|
`Kasus ${randomCategory.name.toLowerCase()} ${location.address}`,
|
|
`Laporan ${randomCategory.name.toLowerCase()} terjadi pada ${timestamp} ${randomLocation}`,
|
|
`${randomCategory.name} dilaporkan ${randomLocation}`,
|
|
`Insiden ${randomCategory.name.toLowerCase()} terjadi ${randomLocation}`,
|
|
`Kejadian ${randomCategory.name.toLowerCase()} ${randomLocation}`,
|
|
`${randomCategory.name} terdeteksi ${randomLocation} pada ${timestamp.toLocaleTimeString()}`,
|
|
`Pelaporan ${randomCategory.name.toLowerCase()} di ${location.address}`,
|
|
`Kasus ${randomCategory.name.toLowerCase()} terjadi di ${streetName}`,
|
|
`${randomCategory.name} terjadi di dekat ${placeType.toLowerCase()} ${district.name}`,
|
|
`Insiden ${randomCategory.name.toLowerCase()} dilaporkan warga setempat ${randomLocation}`,
|
|
];
|
|
|
|
const randomDescription =
|
|
descriptions[Math.floor(Math.random() * descriptions.length)];
|
|
|
|
// Create the crime incident
|
|
const incident = await this.prisma.crime_incidents.create({
|
|
data: {
|
|
id: incidentId,
|
|
crime_id: crime.id,
|
|
crime_category_id: randomCategory.id,
|
|
location_id: location.id,
|
|
description: randomDescription,
|
|
victim_count: 0,
|
|
status: status,
|
|
timestamp: timestamp,
|
|
},
|
|
});
|
|
|
|
incidentsCreated.push(incident);
|
|
}
|
|
|
|
return incidentsCreated;
|
|
}
|
|
}
|
|
|
|
// This allows the file to be executed standalone for testing
|
|
if (require.main === module) {
|
|
const testSeeder = async () => {
|
|
const prisma = new PrismaClient();
|
|
const seeder = new CrimeIncidentsSeeder(prisma);
|
|
try {
|
|
await seeder.run();
|
|
} catch (e) {
|
|
console.error('Error during seeding:', e);
|
|
process.exit(1);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
};
|
|
|
|
testSeeder();
|
|
}
|