feat: 지진파(USGS) + 기압(Open-Meteo) 수집기 + DB 테이블 설계 (#39)
Co-authored-by: htlee <htlee@gcsc.co.kr> Co-committed-by: htlee <htlee@gcsc.co.kr>
This commit is contained in:
부모
0fd32081b0
커밋
4b41ed0d9d
@ -0,0 +1,113 @@
|
|||||||
|
package gc.mda.kcg.collector.sensor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
|
import gc.mda.kcg.domain.sensor.PressureReading;
|
||||||
|
import gc.mda.kcg.domain.sensor.PressureReadingRepository;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PressureCollector {
|
||||||
|
|
||||||
|
private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"};
|
||||||
|
private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08};
|
||||||
|
private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29};
|
||||||
|
|
||||||
|
private static final String OPEN_METEO_URL =
|
||||||
|
"https://api.open-meteo.com/v1/forecast?" +
|
||||||
|
"latitude=35.69,32.65,27.19,29.59,38.08" +
|
||||||
|
"&longitude=51.39,51.68,56.27,52.58,46.29" +
|
||||||
|
"&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PressureReadingRepository repository;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
Thread.ofVirtual().name("pressure-init").start(() -> {
|
||||||
|
log.info("Open-Meteo 기압 데이터 초기 로드");
|
||||||
|
collect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)
|
||||||
|
public void collectScheduled() {
|
||||||
|
collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collect() {
|
||||||
|
try {
|
||||||
|
String body = restTemplate.getForObject(OPEN_METEO_URL, String.class);
|
||||||
|
if (body == null || body.isBlank()) return;
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(body);
|
||||||
|
int saved = 0;
|
||||||
|
Instant now = Instant.now();
|
||||||
|
|
||||||
|
// Open-Meteo 멀티 위치: 배열 형태 응답
|
||||||
|
if (root.isArray()) {
|
||||||
|
for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) {
|
||||||
|
saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 단일 위치 응답 (파라미터 1개일 때)
|
||||||
|
saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.recordSuccess("pressure", "iran", saved);
|
||||||
|
log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved);
|
||||||
|
} catch (Exception e) {
|
||||||
|
tracker.recordFailure("pressure", "iran", e.getMessage());
|
||||||
|
log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) {
|
||||||
|
JsonNode hourly = stationData.path("hourly");
|
||||||
|
JsonNode times = hourly.path("time");
|
||||||
|
JsonNode pressures = hourly.path("surface_pressure");
|
||||||
|
if (!times.isArray() || !pressures.isArray()) return 0;
|
||||||
|
|
||||||
|
int saved = 0;
|
||||||
|
for (int j = 0; j < times.size() && j < pressures.size(); j++) {
|
||||||
|
String timeStr = times.get(j).asText();
|
||||||
|
double pressure = pressures.get(j).asDouble(Double.NaN);
|
||||||
|
if (Double.isNaN(pressure)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) → Z 추가
|
||||||
|
Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant();
|
||||||
|
try {
|
||||||
|
repository.save(PressureReading.builder()
|
||||||
|
.station(station)
|
||||||
|
.lat(lat)
|
||||||
|
.lng(lng)
|
||||||
|
.pressureHpa(pressure)
|
||||||
|
.readingTime(readingTime)
|
||||||
|
.collectedAt(now)
|
||||||
|
.build());
|
||||||
|
saved++;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// unique constraint violation = 이미 존재, 무시
|
||||||
|
}
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
log.debug("기압 시간 파싱 실패: {}", timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
package gc.mda.kcg.collector.sensor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
|
import gc.mda.kcg.domain.sensor.SeismicEvent;
|
||||||
|
import gc.mda.kcg.domain.sensor.SeismicEventRepository;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SeismicCollector {
|
||||||
|
|
||||||
|
private static final String USGS_URL =
|
||||||
|
"https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" +
|
||||||
|
"&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" +
|
||||||
|
"&orderby=time&limit=50&minmagnitude=2";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final SeismicEventRepository repository;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
Thread.ofVirtual().name("seismic-init").start(() -> {
|
||||||
|
log.info("USGS 지진 데이터 초기 로드");
|
||||||
|
collect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
|
||||||
|
public void collectScheduled() {
|
||||||
|
collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collect() {
|
||||||
|
try {
|
||||||
|
String body = restTemplate.getForObject(USGS_URL, String.class);
|
||||||
|
if (body == null || body.isBlank()) return;
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(body);
|
||||||
|
JsonNode features = root.path("features");
|
||||||
|
if (!features.isArray()) return;
|
||||||
|
|
||||||
|
int saved = 0;
|
||||||
|
Instant now = Instant.now();
|
||||||
|
for (JsonNode f : features) {
|
||||||
|
JsonNode props = f.path("properties");
|
||||||
|
JsonNode coords = f.path("geometry").path("coordinates");
|
||||||
|
|
||||||
|
String usgsId = f.path("id").asText(null);
|
||||||
|
if (usgsId == null) continue;
|
||||||
|
if (repository.existsByUsgsId(usgsId)) continue;
|
||||||
|
|
||||||
|
double mag = props.path("mag").asDouble(0);
|
||||||
|
String place = props.path("place").asText(null);
|
||||||
|
long timeMs = props.path("time").asLong(0);
|
||||||
|
double lng = coords.get(0).asDouble();
|
||||||
|
double lat = coords.get(1).asDouble();
|
||||||
|
Double depth = coords.has(2) ? coords.get(2).asDouble() : null;
|
||||||
|
|
||||||
|
repository.save(SeismicEvent.builder()
|
||||||
|
.usgsId(usgsId)
|
||||||
|
.magnitude(mag)
|
||||||
|
.depth(depth)
|
||||||
|
.lat(lat)
|
||||||
|
.lng(lng)
|
||||||
|
.place(place)
|
||||||
|
.eventTime(Instant.ofEpochMilli(timeMs))
|
||||||
|
.collectedAt(now)
|
||||||
|
.build());
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
tracker.recordSuccess("seismic", "iran", saved);
|
||||||
|
log.debug("USGS 지진 수집 완료: {}건 저장", saved);
|
||||||
|
} catch (Exception e) {
|
||||||
|
tracker.recordFailure("seismic", "iran", e.getMessage());
|
||||||
|
log.warn("USGS 지진 수집 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,13 +18,16 @@ public class CacheConfig {
|
|||||||
public static final String OSINT_IRAN = "osint-iran";
|
public static final String OSINT_IRAN = "osint-iran";
|
||||||
public static final String OSINT_KOREA = "osint-korea";
|
public static final String OSINT_KOREA = "osint-korea";
|
||||||
public static final String SATELLITES = "satellites";
|
public static final String SATELLITES = "satellites";
|
||||||
|
public static final String SEISMIC = "seismic";
|
||||||
|
public static final String PRESSURE = "pressure";
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
CaffeineCacheManager manager = new CaffeineCacheManager(
|
CaffeineCacheManager manager = new CaffeineCacheManager(
|
||||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||||
OSINT_IRAN, OSINT_KOREA,
|
OSINT_IRAN, OSINT_KOREA,
|
||||||
SATELLITES
|
SATELLITES,
|
||||||
|
SEISMIC, PRESSURE
|
||||||
);
|
);
|
||||||
manager.setCaffeine(Caffeine.newBuilder()
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "pressure_readings",
|
||||||
|
schema = "kcg",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class PressureReading {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String station;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double lat;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double lng;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double pressureHpa;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant readingTime;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant collectedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (collectedAt == null) {
|
||||||
|
collectedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PressureReadingRepository extends JpaRepository<PressureReading, Long> {
|
||||||
|
List<PressureReading> findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since);
|
||||||
|
List<PressureReading> findByReadingTimeAfterOrderByReadingTimeAsc(Instant since);
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "seismic_events", schema = "kcg")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class SeismicEvent {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false, length = 30)
|
||||||
|
private String usgsId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double magnitude;
|
||||||
|
|
||||||
|
private Double depth;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double lat;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double lng;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String place;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant eventTime;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant collectedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (collectedAt == null) {
|
||||||
|
collectedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface SeismicEventRepository extends JpaRepository<SeismicEvent, Long> {
|
||||||
|
boolean existsByUsgsId(String usgsId);
|
||||||
|
List<SeismicEvent> findByEventTimeAfterOrderByEventTimeDesc(Instant since);
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/sensor")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SensorController {
|
||||||
|
|
||||||
|
private final SeismicEventRepository seismicRepo;
|
||||||
|
private final PressureReadingRepository pressureRepo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지진 이벤트 조회 (USGS 수집 데이터)
|
||||||
|
*/
|
||||||
|
@GetMapping("/seismic")
|
||||||
|
public Map<String, Object> getSeismic(
|
||||||
|
@RequestParam(defaultValue = "24") int hours) {
|
||||||
|
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||||
|
List<SensorDto.SeismicDto> data = seismicRepo
|
||||||
|
.findByEventTimeAfterOrderByEventTimeDesc(since)
|
||||||
|
.stream().map(SensorDto.SeismicDto::from).toList();
|
||||||
|
return Map.of("count", data.size(), "data", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기압 데이터 조회 (Open-Meteo 수집 데이터)
|
||||||
|
*/
|
||||||
|
@GetMapping("/pressure")
|
||||||
|
public Map<String, Object> getPressure(
|
||||||
|
@RequestParam(defaultValue = "24") int hours) {
|
||||||
|
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||||
|
List<SensorDto.PressureDto> data = pressureRepo
|
||||||
|
.findByReadingTimeAfterOrderByReadingTimeAsc(since)
|
||||||
|
.stream().map(SensorDto.PressureDto::from).toList();
|
||||||
|
return Map.of("count", data.size(), "data", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package gc.mda.kcg.domain.sensor;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
public class SensorDto {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public static class SeismicDto {
|
||||||
|
private String usgsId;
|
||||||
|
private double magnitude;
|
||||||
|
private Double depth;
|
||||||
|
private double lat;
|
||||||
|
private double lng;
|
||||||
|
private String place;
|
||||||
|
private long timestamp; // epoch ms
|
||||||
|
|
||||||
|
public static SeismicDto from(SeismicEvent e) {
|
||||||
|
return SeismicDto.builder()
|
||||||
|
.usgsId(e.getUsgsId())
|
||||||
|
.magnitude(e.getMagnitude())
|
||||||
|
.depth(e.getDepth())
|
||||||
|
.lat(e.getLat())
|
||||||
|
.lng(e.getLng())
|
||||||
|
.place(e.getPlace())
|
||||||
|
.timestamp(e.getEventTime().toEpochMilli())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public static class PressureDto {
|
||||||
|
private String station;
|
||||||
|
private double lat;
|
||||||
|
private double lng;
|
||||||
|
private double pressureHpa;
|
||||||
|
private long timestamp; // epoch ms
|
||||||
|
|
||||||
|
public static PressureDto from(PressureReading r) {
|
||||||
|
return PressureDto.builder()
|
||||||
|
.station(r.getStation())
|
||||||
|
.lat(r.getLat())
|
||||||
|
.lng(r.getLng())
|
||||||
|
.pressureHpa(r.getPressureHpa())
|
||||||
|
.timestamp(r.getReadingTime().toEpochMilli())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
database/migration/004_sensor_data.sql
Normal file
30
database/migration/004_sensor_data.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
SET search_path TO kcg, public;
|
||||||
|
|
||||||
|
-- 지진 이벤트 (USGS)
|
||||||
|
CREATE TABLE IF NOT EXISTS seismic_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
usgs_id VARCHAR(30) UNIQUE NOT NULL,
|
||||||
|
magnitude DOUBLE PRECISION NOT NULL,
|
||||||
|
depth DOUBLE PRECISION,
|
||||||
|
lat DOUBLE PRECISION NOT NULL,
|
||||||
|
lng DOUBLE PRECISION NOT NULL,
|
||||||
|
place VARCHAR(255),
|
||||||
|
event_time TIMESTAMPTZ NOT NULL,
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC);
|
||||||
|
|
||||||
|
-- 기압 데이터 (Open-Meteo)
|
||||||
|
CREATE TABLE IF NOT EXISTS pressure_readings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
station VARCHAR(50) NOT NULL,
|
||||||
|
lat DOUBLE PRECISION NOT NULL,
|
||||||
|
lng DOUBLE PRECISION NOT NULL,
|
||||||
|
pressure_hpa DOUBLE PRECISION NOT NULL,
|
||||||
|
reading_time TIMESTAMPTZ NOT NULL,
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(station, reading_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC);
|
||||||
@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기
|
||||||
|
- 기압 수집기: Open-Meteo API, 이란 5개 관측점(테헤란/이스파한/반다르아바스/시라즈/타브리즈), 10분 주기
|
||||||
|
- DB 테이블: seismic_events, pressure_readings (마이그레이션 004)
|
||||||
|
- REST API: GET /api/sensor/seismic, GET /api/sensor/pressure
|
||||||
|
- SensorChart 그래프 순서 변경: 지진파 → 기압 → 소음 → 방사선
|
||||||
|
- 소음/방사선 차트에 (DEMO) 라벨 표시
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리
|
- 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리
|
||||||
- App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출)
|
- App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출)
|
||||||
|
|||||||
@ -1131,6 +1131,12 @@
|
|||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-demo-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #ef4444;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer / Controls */
|
/* Footer / Controls */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
|||||||
@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
|
||||||
<h4>{t('sensor.noiseLevelDb')}</h4>
|
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
|
||||||
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
|
|
||||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
|
||||||
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>{t('sensor.airPressureHpa')}</h4>
|
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>{t('sensor.radiationUsv')}</h4>
|
<h4>
|
||||||
|
{t('sensor.noiseLevelDb')}{' '}
|
||||||
|
<span className="chart-demo-label">(DEMO)</span>
|
||||||
|
</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
|
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||||
|
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||||
|
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||||
|
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-item">
|
||||||
|
<h4>
|
||||||
|
{t('sensor.radiationUsv')}{' '}
|
||||||
|
<span className="chart-demo-label">(DEMO)</span>
|
||||||
|
</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user