-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
441 lines (395 loc) · 20.7 KB
/
Copy pathserver.ts
File metadata and controls
441 lines (395 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
import express from "express";
import path from "path";
import { createServer as createViteServer } from "vite";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const PORT = 3000;
// Setup JSON parsing
app.use(express.json());
// List of major expressways in Korea for simulation and mapping
interface HighwayRoute {
routeNo: string;
routeName: string;
totalLengthKm: number;
sections: {
startName: string;
endName: string;
lengthKm: number;
baseSpeedLimit: number;
}[];
}
const HIGHWAY_ROUTES: HighwayRoute[] = [
{
routeNo: "0010",
routeName: "경부고속도로 (경부선)",
totalLengthKm: 416.1,
sections: [
{ startName: "양재IC", endName: "판교JC", lengthKm: 7.5, baseSpeedLimit: 100 },
{ startName: "판교JC", endName: "신갈JC", lengthKm: 11.2, baseSpeedLimit: 100 },
{ startName: "신갈JC", endName: "기흥IC", lengthKm: 6.8, baseSpeedLimit: 100 },
{ startName: "기흥IC", endName: "오산IC", lengthKm: 8.5, baseSpeedLimit: 100 },
{ startName: "오산IC", endName: "안성JC", lengthKm: 12.3, baseSpeedLimit: 110 },
{ startName: "안성JC", endName: "천안JC", lengthKm: 21.0, baseSpeedLimit: 110 },
{ startName: "천안JC", endName: "목천IC", lengthKm: 4.2, baseSpeedLimit: 110 },
{ startName: "목천IC", endName: "청주JC", lengthKm: 26.5, baseSpeedLimit: 110 },
{ startName: "청주JC", endName: "회덕JC", lengthKm: 22.1, baseSpeedLimit: 110 },
{ startName: "회덕JC", endName: "비룡JC", lengthKm: 9.3, baseSpeedLimit: 100 },
{ startName: "비룡JC", endName: "금호JC", lengthKm: 135.2, baseSpeedLimit: 100 },
{ startName: "금호JC", endName: "동대구JC", lengthKm: 18.5, baseSpeedLimit: 100 },
{ startName: "동대구JC", endName: "경주IC", lengthKm: 52.4, baseSpeedLimit: 100 },
{ startName: "경주IC", endName: "노포JC", lengthKm: 45.1, baseSpeedLimit: 100 },
{ startName: "노포JC", endName: "구서IC (부산)", lengthKm: 5.1, baseSpeedLimit: 80 }
]
},
{
routeNo: "1000",
routeName: "수도권제1순환고속도로",
totalLengthKm: 128.0,
sections: [
{ startName: "판교JC", endName: "성남IC", lengthKm: 4.2, baseSpeedLimit: 100 },
{ startName: "성남IC", endName: "송파IC", lengthKm: 5.8, baseSpeedLimit: 100 },
{ startName: "송파IC", endName: "서하남IC", lengthKm: 5.1, baseSpeedLimit: 100 },
{ startName: "서하남IC", endName: "하남JC", lengthKm: 4.9, baseSpeedLimit: 100 },
{ startName: "하남JC", endName: "토평IC", lengthKm: 3.1, baseSpeedLimit: 100 },
{ startName: "토평IC", endName: "구리IC", lengthKm: 2.8, baseSpeedLimit: 100 },
{ startName: "구리IC", endName: "퇴계원IC", lengthKm: 2.6, baseSpeedLimit: 100 },
{ startName: "퇴계원IC", endName: "의정부IC", lengthKm: 11.2, baseSpeedLimit: 100 },
{ startName: "의정부IC", endName: "송추IC", lengthKm: 7.2, baseSpeedLimit: 100 },
{ startName: "송추IC", endName: "김포IC", lengthKm: 21.3, baseSpeedLimit: 100 },
{ startName: "김포IC", endName: "계양IC", lengthKm: 3.5, baseSpeedLimit: 100 },
{ startName: "계양IC", endName: "서운JC", lengthKm: 2.3, baseSpeedLimit: 100 },
{ startName: "서운JC", endName: "송내IC", lengthKm: 5.1, baseSpeedLimit: 100 },
{ startName: "송내IC", endName: "장수IC", lengthKm: 2.4, baseSpeedLimit: 100 },
{ startName: "장수IC", endName: "시흥IC", lengthKm: 4.2, baseSpeedLimit: 100 },
{ startName: "시흥IC", endName: "안현JC", lengthKm: 2.7, baseSpeedLimit: 100 },
{ startName: "안현JC", endName: "학의JC", lengthKm: 12.8, baseSpeedLimit: 100 },
{ startName: "학의JC", endName: "판교JC", lengthKm: 8.8, baseSpeedLimit: 100 }
]
},
{
routeNo: "0150",
routeName: "서해안고속도로",
totalLengthKm: 336.6,
sections: [
{ startName: "금천IC (서울)", endName: "일직JC", lengthKm: 3.5, baseSpeedLimit: 100 },
{ startName: "일직JC", endName: "소하JC", lengthKm: 1.2, baseSpeedLimit: 100 },
{ startName: "소하JC", endName: "조남JC", lengthKm: 8.5, baseSpeedLimit: 100 },
{ startName: "조남JC", endName: "목감IC", lengthKm: 3.2, baseSpeedLimit: 100 },
{ startName: "목감IC", endName: "매송IC", lengthKm: 11.1, baseSpeedLimit: 100 },
{ startName: "매송IC", endName: "비봉IC", lengthKm: 4.2, baseSpeedLimit: 100 },
{ startName: "비봉IC", endName: "발안IC", lengthKm: 13.8, baseSpeedLimit: 110 },
{ startName: "발안IC", endName: "서평택JC", lengthKm: 14.5, baseSpeedLimit: 110 },
{ startName: "서평택JC", endName: "서해대교", lengthKm: 8.2, baseSpeedLimit: 110 },
{ startName: "서해대교", endName: "송악IC", lengthKm: 5.5, baseSpeedLimit: 110 },
{ startName: "송악IC", endName: "당진JC", lengthKm: 8.3, baseSpeedLimit: 110 },
{ startName: "당진JC", endName: "서산IC", lengthKm: 12.1, baseSpeedLimit: 110 },
{ startName: "서산IC", endName: "군산IC", lengthKm: 98.4, baseSpeedLimit: 110 },
{ startName: "군산IC", endName: "목포IC", lengthKm: 144.1, baseSpeedLimit: 110 }
]
},
{
routeNo: "0500",
routeName: "영동고속도로",
totalLengthKm: 234.4,
sections: [
{ startName: "서창JC (인천)", endName: "안산JC", lengthKm: 15.2, baseSpeedLimit: 100 },
{ startName: "안산JC", endName: "신갈JC", lengthKm: 21.5, baseSpeedLimit: 100 },
{ startName: "신갈JC", endName: "마성IC", lengthKm: 6.2, baseSpeedLimit: 100 },
{ startName: "마성IC", endName: "용인IC", lengthKm: 4.5, baseSpeedLimit: 100 },
{ startName: "용인IC", endName: "양지IC", lengthKm: 7.2, baseSpeedLimit: 100 },
{ startName: "양지IC", endName: "덕평IC", lengthKm: 7.8, baseSpeedLimit: 100 },
{ startName: "덕평IC", endName: "이천IC", lengthKm: 6.5, baseSpeedLimit: 100 },
{ startName: "이천IC", endName: "여주JC", lengthKm: 11.2, baseSpeedLimit: 100 },
{ startName: "여주JC", endName: "원주JC", lengthKm: 31.8, baseSpeedLimit: 100 },
{ startName: "원주JC", endName: "평창IC", lengthKm: 65.5, baseSpeedLimit: 100 },
{ startName: "평창IC", endName: "강릉JC", lengthKm: 56.6, baseSpeedLimit: 100 }
]
},
{
routeNo: "0450",
routeName: "중부내륙고속도로",
totalLengthKm: 301.6,
sections: [
{ startName: "양평IC", endName: "여주JC", lengthKm: 31.2, baseSpeedLimit: 110 },
{ startName: "여주JC", endName: "감곡IC", lengthKm: 14.5, baseSpeedLimit: 110 },
{ startName: "감곡IC", endName: "충주JC", lengthKm: 22.8, baseSpeedLimit: 110 },
{ startName: "충주JC", endName: "괴산IC", lengthKm: 18.2, baseSpeedLimit: 110 },
{ startName: "괴산IC", endName: "문경새재IC", lengthKm: 19.5, baseSpeedLimit: 110 },
{ startName: "문경새재IC", endName: "상주JC", lengthKm: 31.4, baseSpeedLimit: 110 },
{ startName: "상주JC", endName: "김천JC", lengthKm: 45.1, baseSpeedLimit: 110 },
{ startName: "김천JC", endName: "고령JC", lengthKm: 58.2, baseSpeedLimit: 100 },
{ startName: "고령JC", endName: "지저JC", lengthKm: 28.5, baseSpeedLimit: 100 },
{ startName: "지저JC", endName: "내서JC (창원)", lengthKm: 32.2, baseSpeedLimit: 100 }
]
}
];
// Generate simulated traffic data based on current Korean time
function generateSimulatedTraffic(routeNoFilter?: string) {
// Compute current Korean local time (UTC + 9)
const nowUtc = new Date();
const koreaOffset = 9 * 60 * 60 * 1000;
const koreaTime = new Date(nowUtc.getTime() + koreaOffset);
const hour = koreaTime.getHours();
const minute = koreaTime.getMinutes();
// Base congestion factors depending on time of day
// Rush hours: 7:30 - 9:30 AM (8), 5:30 - 7:30 PM (17-19)
let congestionMultiplier = 1.0;
let rushHourType = "none";
if (hour === 7 && minute >= 30 || hour === 8 || (hour === 9 && minute <= 30)) {
congestionMultiplier = 3.2; // Morning rush hour
rushHourType = "morning";
} else if (hour === 17 && minute >= 30 || hour === 18 || hour === 19 || (hour === 20 && minute <= 0)) {
congestionMultiplier = 3.5; // Evening rush hour
rushHourType = "evening";
} else if (hour >= 23 || hour < 6) {
congestionMultiplier = 0.2; // Late night (free flow)
rushHourType = "night";
} else if (hour >= 11 && hour <= 14) {
congestionMultiplier = 1.5; // Midday lunch moderate delay
rushHourType = "lunch";
} else {
congestionMultiplier = 1.0; // Standard day traffic
rushHourType = "day";
}
const congestList: any[] = [];
const activeIncidents: any[] = [];
// Seed standard weather conditions
let weatherCondition = "Clear";
let weatherDesc = "맑음";
// Randomly vary weather based on day of week or current minute to make it feel real-time
const dayOfWeek = koreaTime.getDay();
if (dayOfWeek === 2 && hour < 12) {
weatherCondition = "Rain";
weatherDesc = "비 (약함)";
} else if (dayOfWeek === 4 && hour > 15) {
weatherCondition = "Fog";
weatherDesc = "안개 경보";
}
// Generate static incidents
const incidentTemplates = [
{
routeNo: "0010",
routeName: "경부선",
sections: ["판교JC - 신갈JC", "안성JC - 천안JC", "양재IC - 판교JC"],
types: ["accident", "construction"],
titles: ["추돌 사고 발생", "갓길 보수 작업 중", "화물차 낙하물 수거 작업"],
details: ["신갈분기점 부근 2차로에서 승용차 3중 추돌 사고가 발생하여 후방 4km 구간 심한 정체입니다.", "갓길 포장 상태 불량으로 차로 긴급 통제 및 정비 보수 작업 중입니다.", "3차로에 낙하물이 있어 서행 수거 중이며, 진입 차량 주의바랍니다."]
},
{
routeNo: "1000",
routeName: "수도권1순환선",
sections: ["송내IC - 장수IC", "김포IC - 계양IC", "의정부IC - 송추IC"],
types: ["accident", "construction", "weather"],
titles: ["추돌 사고", "이정표 정비 공사", "안개로 인한 감속 안내"],
details: ["장수IC 인근 1차로 부근 승용차 접촉 사고로 후방 정체가 극심합니다.", "노후 안내 표지판 설치 작업을 위해 3차로 일시 차단 중입니다.", "송추 터널 구간 국지성 안개 유입으로 안전 운전 및 80km 감속이 필요합니다."]
},
{
routeNo: "0150",
routeName: "서해안선",
sections: ["서평택JC - 서해대교", "금천IC (서울) - 일직JC"],
types: ["weather", "accident"],
titles: ["강풍 및 안개 주의보", "접촉 사고 수습 중"],
details: ["서해대교 구간 해상 강풍(풍속 12m/s) 및 안개로 주행 속도 50% 감속 제한 중입니다.", "일직분기점 부근 사고 잔해물 수습이 거의 완료되었으나 후방 지체 여파가 남아있습니다."]
}
];
// Determine which routes to generate
const targetRoutes = routeNoFilter
? HIGHWAY_ROUTES.filter(r => r.routeNo === routeNoFilter)
: HIGHWAY_ROUTES;
targetRoutes.forEach(route => {
// Potentially add an incident for this route with 40% probability
const hasIncident = (Math.sin(koreaTime.getDate() + Number(route.routeNo)) + 1) / 2 > 0.6;
let routeIncidentSection = "";
if (hasIncident) {
const template = incidentTemplates.find(t => t.routeNo === route.routeNo);
if (template) {
const secIndex = koreaTime.getMinutes() % template.sections.length;
const typeIndex = (koreaTime.getMinutes() + 1) % template.types.length;
const section = template.sections[secIndex];
routeIncidentSection = section.split(" - ")[0]; // Get start section
activeIncidents.push({
id: `inc-${route.routeNo}-${koreaTime.getDate()}`,
routeNo: route.routeNo,
routeName: route.routeName,
section: section,
type: template.types[typeIndex],
title: template.titles[typeIndex],
detail: template.details[typeIndex],
time: `${hour.toString().padStart(2, '0')}:${(minute - (minute % 10)).toString().padStart(2, '0')}`
});
}
}
route.sections.forEach((section, idx) => {
// Create outbound and inbound versions of each section
const directions = [
{ dir: route.routeNo === "1000" ? "내선일주" : "하행(지방)", isOutbound: true },
{ dir: route.routeNo === "1000" ? "외선일주" : "상행(서울)", isOutbound: false }
];
directions.forEach(directionInfo => {
// Base seed using index and date to keep it consistent on brief refreshes, but varied
const seed = Math.sin(idx * 1.5 + (directionInfo.isOutbound ? 1 : 2) + koreaTime.getDate() * 0.5);
const normalizedSeed = (seed + 1) / 2; // 0.0 to 1.0
// Calculate congestion probability
// Sections closer to capital (index smaller in outbound, or index larger in inbound) are more congested
let capitalProximityFactor = 1.0;
if (route.routeNo === "0010" || route.routeNo === "0150") {
// Gyeongbu and Seohaean are heavy near Seoul (small index)
capitalProximityFactor = Math.max(0.2, 1.0 - (idx / route.sections.length));
} else if (route.routeNo === "1000") {
// Circular ring is heavy everywhere but especially around specific nodes (Songnae, Songpa, Gimpo)
const isHeavyNode = section.startName.includes("송내") || section.startName.includes("송파") || section.startName.includes("김포") || section.startName.includes("판교");
capitalProximityFactor = isHeavyNode ? 1.2 : 0.7;
}
// Apply congestion multiplier
const congestionScore = normalizedSeed * capitalProximityFactor * congestionMultiplier;
// Is there an active incident on this exact section?
const incidentOnThisSection = routeIncidentSection && section.startName.startsWith(routeIncidentSection);
let speed = section.baseSpeedLimit;
let congestGrade = "원활";
if (incidentOnThisSection) {
// Drop speed dramatically for accident sections
speed = Math.floor(15 + Math.random() * 15);
congestGrade = "정체";
} else if (congestionScore > 1.8) {
// Severe Congestion
speed = Math.floor(20 + normalizedSeed * 15);
congestGrade = "정체";
} else if (congestionScore > 1.1) {
// Moderate Delay (서행)
speed = Math.floor(40 + normalizedSeed * 25);
congestGrade = "서행";
} else {
// Smooth Flow
speed = Math.floor(section.baseSpeedLimit - (normalizedSeed * 12));
congestGrade = "원활";
}
// Final safety bounds
if (speed < 10) speed = 10;
if (speed > section.baseSpeedLimit) speed = section.baseSpeedLimit;
// Only add to congestion list if it is actually delayed OR if we want to show all sections
// The Korea Expressway API only returns "congestList" for delayed sections (Grade = "정체" or "서행")
congestList.push({
routeName: route.routeName.split(" (")[0],
routeNo: route.routeNo,
direction: directionInfo.dir,
startSectionName: section.startName,
endSectionName: section.endName,
congestSectionLength: section.lengthKm.toFixed(1),
speed: speed.toString(),
congestGrade: congestGrade,
updownType: directionInfo.isOutbound ? "D" : "U"
});
});
});
});
// Calculate some aggregate metrics
const totalSectionsCount = congestList.length;
const delayedSections = congestList.filter(s => s.congestGrade !== "원활");
const heavySectionsCount = congestList.filter(s => s.congestGrade === "정체").length;
const moderateSectionsCount = congestList.filter(s => s.congestGrade === "서행").length;
const averageNetworkSpeed = Math.round(
congestList.reduce((acc, curr) => acc + Number(curr.speed), 0) / totalSectionsCount
);
// Format Korean date string cleanly
const formattedDate = `${koreaTime.getFullYear()}-${(koreaTime.getMonth() + 1).toString().padStart(2, '0')}-${koreaTime.getDate().toString().padStart(2, '0')} ${koreaTime.getHours().toString().padStart(2, '0')}:${koreaTime.getMinutes().toString().padStart(2, '0')}:${koreaTime.getSeconds().toString().padStart(2, '0')}`;
return {
status: "fallback",
apiSource: "https://data.ex.co.kr/openapi/trfcState/congest",
rushHourType: rushHourType,
congestList: congestList,
incidents: activeIncidents,
weather: {
condition: weatherCondition,
description: weatherDesc
},
metrics: {
totalSections: totalSectionsCount,
delayedSectionsCount: delayedSections.length,
heavyCongestionCount: heavySectionsCount,
moderateDelayCount: moderateSectionsCount,
averageSpeedKmh: averageNetworkSpeed
},
lastUpdated: formattedDate,
koreaTime: koreaTime.toISOString()
};
}
// REST Endpoint to fetch highway traffic
app.get("/api/traffic", async (req, res) => {
const { routeNo, key } = req.query;
const targetRouteNo = routeNo as string | undefined;
// Choose API key: Use client provided key, or server-side environment key, or fallback
const apiKey = (key as string) || process.env.EX_API_KEY || "5540954313";
// Base URL for the EX Portal traffic congestion endpoint (apiId = 0102)
const apiUrl = `https://data.ex.co.kr/openapi/trfcState/congest?key=${apiKey}&type=json${targetRouteNo ? `&routeNo=${targetRouteNo}` : ""}`;
console.log(`[API Proxy] Requesting live highway traffic...`);
try {
// Strict timeout of 2.5 seconds using AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
const response = await fetch(apiUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`EX API responded with status ${response.status}`);
}
const data = await response.json();
console.log(`[API Proxy] Successfully fetched live EX API data!`);
// Parse the live data and return it
// Wait, let's verify if the response contains any list.
// Standard response format: { congestList: [...], count: 32, ... }
const congestList = data.congestList || [];
// Sort or filter if needed
const nowUtc = new Date();
const koreaTime = new Date(nowUtc.getTime() + 9 * 60 * 60 * 1000);
const formattedDate = `${koreaTime.getFullYear()}-${(koreaTime.getMonth() + 1).toString().padStart(2, '0')}-${koreaTime.getDate().toString().padStart(2, '0')} ${koreaTime.getHours().toString().padStart(2, '0')}:${koreaTime.getMinutes().toString().padStart(2, '0')}:${koreaTime.getSeconds().toString().padStart(2, '0')}`;
// Add aggregate statistics to the live data to power our dashboard uniformly
const totalSpeedSum = congestList.reduce((acc: number, curr: any) => acc + (Number(curr.speed) || 80), 0);
const avgSpeed = congestList.length > 0 ? Math.round(totalSpeedSum / congestList.length) : 85;
return res.json({
status: "live",
apiSource: apiUrl,
congestList: congestList,
incidents: data.incidents || [], // If the live API returns current incidents, include them
weather: {
condition: "Clear",
description: "맑음"
},
metrics: {
totalSections: congestList.length,
delayedSectionsCount: congestList.filter((s: any) => s.congestGrade !== "원활").length,
heavyCongestionCount: congestList.filter((s: any) => s.congestGrade === "정체").length,
moderateDelayCount: congestList.filter((s: any) => s.congestGrade === "서행").length,
averageSpeedKmh: avgSpeed
},
lastUpdated: formattedDate,
koreaTime: koreaTime.toISOString()
});
} catch (error: any) {
console.warn(`[API Proxy Warning] Live EX API connection failed or timed out: ${error.message || error}. Falling back to real-time simulation engine.`);
// Fall back to localized high-precision traffic simulation
const simulatedData = generateSimulatedTraffic(targetRouteNo);
return res.json(simulatedData);
}
});
// Serve static assets or mount Vite middleware
async function startServer() {
if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "spa",
});
app.use(vite.middlewares);
console.log("[Vite] Middleware mounted in development mode.");
} else {
const distPath = path.join(process.cwd(), "dist");
app.use(express.static(distPath));
app.get("*", (req, res) => {
res.sendFile(path.join(distPath, "index.html"));
});
console.log("[Server] Production static folder served.");
}
app.listen(PORT, "0.0.0.0", () => {
console.log(`[Express] Full-stack Server running at http://localhost:${PORT}`);
});
}
startServer();