fix: [dashboard] 날씨 위젯 API 호출 수정 및 UI/UX 개선
- serviceKey 이중 인코딩 방지 (withOptions 사용) - 오늘부터 7일간 표시 (데이터 없는 날은 '준비 중') - 기온 바 그래프 추가 (파랑→빨강 그라데이션) - 날씨 텍스트 표시, 강수일 강조
This commit is contained in:
@@ -55,32 +55,66 @@ public function getWeeklyForecast(): array
|
||||
private function buildForecast(): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
$forecasts = [];
|
||||
|
||||
// 1) 단기예보 (오늘 ~ +2일, 최대 3일)
|
||||
$short = $this->fetchShortForecast();
|
||||
foreach ($short as $date => $data) {
|
||||
$forecasts[$date] = $data;
|
||||
}
|
||||
|
||||
// 2) 중기기온 + 중기날씨 (3일 ~ 7일)
|
||||
// 2) 중기기온 + 중기날씨 (4일 ~ 10일)
|
||||
$midTa = $this->fetchMidTemperature();
|
||||
$midLand = $this->fetchMidLandForecast();
|
||||
|
||||
for ($i = 3; $i <= 7; $i++) {
|
||||
// 오늘부터 7일간 데이터 조합
|
||||
$forecasts = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$date = $today->copy()->addDays($i)->format('Ymd');
|
||||
$forecasts[$date] = [
|
||||
|
||||
// 단기예보 데이터 우선
|
||||
if (isset($short[$date])) {
|
||||
$forecasts[] = $short[$date];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 중기예보 데이터
|
||||
$tmn = $midTa["taMin{$i}"] ?? null;
|
||||
$tmx = $midTa["taMax{$i}"] ?? null;
|
||||
$wf = $midLand["wf{$i}Am"] ?? ($midLand["wf{$i}"] ?? '');
|
||||
|
||||
$forecasts[] = [
|
||||
'date' => $date,
|
||||
'tmn' => $midTa["taMin{$i}"] ?? null,
|
||||
'tmx' => $midTa["taMax{$i}"] ?? null,
|
||||
'icon' => $this->midWeatherToIcon($midLand["wf{$i}Am"] ?? ($midLand["wf{$i}"] ?? '')),
|
||||
'tmn' => $tmn,
|
||||
'tmx' => $tmx,
|
||||
'icon' => ! empty($wf) ? $this->midWeatherToIcon($wf) : null,
|
||||
'weather_text' => $wf,
|
||||
];
|
||||
}
|
||||
|
||||
// 날짜순 정렬 후 7일만
|
||||
ksort($forecasts);
|
||||
return $forecasts;
|
||||
}
|
||||
|
||||
return array_slice(array_values($forecasts), 0, 7);
|
||||
/**
|
||||
* 기상청 API 호출 헬퍼 — serviceKey 이중 인코딩 방지
|
||||
*/
|
||||
private function callApi(string $baseUrl, array $params): ?array
|
||||
{
|
||||
$params['serviceKey'] = $this->serviceKey;
|
||||
$params['dataType'] = 'JSON';
|
||||
$queryString = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$response = Http::timeout(10)
|
||||
->withOptions(['query' => $queryString])
|
||||
->get($baseUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('WeatherService API 실패', [
|
||||
'url' => $baseUrl,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,30 +123,22 @@ private function buildForecast(): array
|
||||
private function fetchShortForecast(): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
// base_date: 오늘, base_time: 0200 (02시 발표 → TMN/TMX 포함)
|
||||
$baseDate = $now->hour < 2 ? $now->copy()->subDay()->format('Ymd') : $now->format('Ymd');
|
||||
$baseTime = '0200';
|
||||
|
||||
$response = Http::timeout(10)->get(self::BASE_SHORT, [
|
||||
'serviceKey' => $this->serviceKey,
|
||||
$body = $this->callApi(self::BASE_SHORT, [
|
||||
'pageNo' => 1,
|
||||
'numOfRows' => 1000,
|
||||
'dataType' => 'JSON',
|
||||
'base_date' => $baseDate,
|
||||
'base_time' => $baseTime,
|
||||
'base_time' => '0200',
|
||||
'nx' => self::NX,
|
||||
'ny' => self::NY,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('WeatherService: 단기예보 API 실패', ['status' => $response->status()]);
|
||||
|
||||
if (! $body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$body = $response->json();
|
||||
$items = data_get($body, 'response.body.items.item', []);
|
||||
|
||||
if (empty($items)) {
|
||||
return [];
|
||||
}
|
||||
@@ -137,14 +163,15 @@ private function fetchShortForecast(): array
|
||||
};
|
||||
}
|
||||
|
||||
// 아이콘 매핑
|
||||
$result = [];
|
||||
foreach ($grouped as $date => $data) {
|
||||
$icon = $this->shortWeatherToIcon($data['sky'] ?? 1, $data['pty'] ?? 0);
|
||||
$result[$date] = [
|
||||
'date' => $date,
|
||||
'tmn' => $data['tmn'],
|
||||
'tmx' => $data['tmx'],
|
||||
'icon' => $this->shortWeatherToIcon($data['sky'] ?? 1, $data['pty'] ?? 0),
|
||||
'icon' => $icon,
|
||||
'weather_text' => $this->iconToText($icon),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -156,22 +183,18 @@ private function fetchShortForecast(): array
|
||||
*/
|
||||
private function fetchMidTemperature(): array
|
||||
{
|
||||
$tmFc = $this->getMidBaseTmFc();
|
||||
|
||||
$response = Http::timeout(10)->get(self::BASE_MID_TA, [
|
||||
'serviceKey' => $this->serviceKey,
|
||||
$body = $this->callApi(self::BASE_MID_TA, [
|
||||
'pageNo' => 1,
|
||||
'numOfRows' => 10,
|
||||
'dataType' => 'JSON',
|
||||
'regId' => self::MID_TA_REG_ID,
|
||||
'tmFc' => $tmFc,
|
||||
'tmFc' => $this->getMidBaseTmFc(),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
if (! $body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = data_get($response->json(), 'response.body.items.item', []);
|
||||
$items = data_get($body, 'response.body.items.item', []);
|
||||
|
||||
return $items[0] ?? [];
|
||||
}
|
||||
@@ -181,22 +204,18 @@ private function fetchMidTemperature(): array
|
||||
*/
|
||||
private function fetchMidLandForecast(): array
|
||||
{
|
||||
$tmFc = $this->getMidBaseTmFc();
|
||||
|
||||
$response = Http::timeout(10)->get(self::BASE_MID_LAND, [
|
||||
'serviceKey' => $this->serviceKey,
|
||||
$body = $this->callApi(self::BASE_MID_LAND, [
|
||||
'pageNo' => 1,
|
||||
'numOfRows' => 10,
|
||||
'dataType' => 'JSON',
|
||||
'regId' => self::MID_LAND_REG_ID,
|
||||
'tmFc' => $tmFc,
|
||||
'tmFc' => $this->getMidBaseTmFc(),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
if (! $body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = data_get($response->json(), 'response.body.items.item', []);
|
||||
$items = data_get($body, 'response.body.items.item', []);
|
||||
|
||||
return $items[0] ?? [];
|
||||
}
|
||||
@@ -219,28 +238,25 @@ private function getMidBaseTmFc(): string
|
||||
|
||||
/**
|
||||
* 단기예보 SKY + PTY → 아이콘 이름
|
||||
* PTY 우선: 비/눈/진눈깨비가 있으면 해당 아이콘
|
||||
*/
|
||||
private function shortWeatherToIcon(int $sky, int $pty): string
|
||||
{
|
||||
// PTY (강수형태) 우선
|
||||
if ($pty > 0) {
|
||||
return match ($pty) {
|
||||
1, 4 => 'rain', // 비, 소나기
|
||||
2 => 'sleet', // 비/눈
|
||||
3 => 'snow', // 눈
|
||||
5 => 'rain', // 빗방울
|
||||
6 => 'sleet', // 빗방울/눈날림
|
||||
7 => 'snow', // 눈날림
|
||||
1, 4 => 'rain',
|
||||
2 => 'sleet',
|
||||
3 => 'snow',
|
||||
5 => 'rain',
|
||||
6 => 'sleet',
|
||||
7 => 'snow',
|
||||
default => 'cloud',
|
||||
};
|
||||
}
|
||||
|
||||
// SKY (하늘상태)
|
||||
return match ($sky) {
|
||||
1 => 'sun', // 맑음
|
||||
3 => 'cloud-sun', // 구름많음
|
||||
4 => 'cloud', // 흐림
|
||||
1 => 'sun',
|
||||
3 => 'cloud-sun',
|
||||
4 => 'cloud',
|
||||
default => 'sun',
|
||||
};
|
||||
}
|
||||
@@ -254,8 +270,6 @@ private function midWeatherToIcon(string $wf): string
|
||||
return 'cloud';
|
||||
}
|
||||
|
||||
$wf = mb_strtolower($wf);
|
||||
|
||||
if (str_contains($wf, '눈') && str_contains($wf, '비')) {
|
||||
return 'sleet';
|
||||
}
|
||||
@@ -268,7 +282,7 @@ private function midWeatherToIcon(string $wf): string
|
||||
if (str_contains($wf, '흐림')) {
|
||||
return 'cloud';
|
||||
}
|
||||
if (str_contains($wf, '구름')) {
|
||||
if (str_contains($wf, '구름많음') || str_contains($wf, '구름')) {
|
||||
return 'cloud-sun';
|
||||
}
|
||||
if (str_contains($wf, '맑음')) {
|
||||
@@ -277,4 +291,20 @@ private function midWeatherToIcon(string $wf): string
|
||||
|
||||
return 'cloud';
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이콘 → 한글 텍스트
|
||||
*/
|
||||
private function iconToText(string $icon): string
|
||||
{
|
||||
return match ($icon) {
|
||||
'sun' => '맑음',
|
||||
'cloud-sun' => '구름많음',
|
||||
'cloud' => '흐림',
|
||||
'rain' => '비',
|
||||
'snow' => '눈',
|
||||
'sleet' => '비/눈',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{-- 주간 날씨 위젯 (HTMX 파티셜) --}}
|
||||
@if(empty($forecasts))
|
||||
<div class="flex items-center justify-center py-6 text-sm text-gray-400">
|
||||
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/>
|
||||
</svg>
|
||||
@@ -10,8 +10,15 @@
|
||||
@php
|
||||
$today = now()->format('Ymd');
|
||||
$dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
// 전체 기온 범위 계산 (바 그래프용)
|
||||
$allTmn = collect($forecasts)->pluck('tmn')->filter(fn($v) => $v !== null);
|
||||
$allTmx = collect($forecasts)->pluck('tmx')->filter(fn($v) => $v !== null);
|
||||
$globalMin = $allTmn->isNotEmpty() ? $allTmn->min() : 0;
|
||||
$globalMax = $allTmx->isNotEmpty() ? $allTmx->max() : 20;
|
||||
$range = max($globalMax - $globalMin, 1);
|
||||
@endphp
|
||||
<div class="flex gap-3 overflow-x-auto pb-1" style="flex-wrap: nowrap;">
|
||||
<div class="flex gap-2 overflow-x-auto pb-1" style="flex-wrap: nowrap;">
|
||||
@foreach($forecasts as $fc)
|
||||
@php
|
||||
$dt = \Carbon\Carbon::createFromFormat('Ymd', $fc['date']);
|
||||
@@ -19,36 +26,87 @@
|
||||
$dayName = $dayNames[$dt->dayOfWeek];
|
||||
$isSunday = $dt->dayOfWeek === 0;
|
||||
$isSaturday = $dt->dayOfWeek === 6;
|
||||
$icon = $fc['icon'] ?? null;
|
||||
$weatherText = $fc['weather_text'] ?? '';
|
||||
$tmn = $fc['tmn'];
|
||||
$tmx = $fc['tmx'];
|
||||
$hasData = ($tmn !== null || $tmx !== null || $icon !== null);
|
||||
|
||||
// 바 위치 계산 (%)
|
||||
$barLeft = $tmn !== null ? round(($tmn - $globalMin) / $range * 100) : 0;
|
||||
$barRight = $tmx !== null ? round(($tmx - $globalMin) / $range * 100) : 100;
|
||||
$barWidth = max($barRight - $barLeft, 8);
|
||||
|
||||
// 강수 여부
|
||||
$isPrecip = in_array($icon, ['rain', 'snow', 'sleet']);
|
||||
@endphp
|
||||
<div class="text-center rounded-xl border px-3 py-4 transition-shadow hover:shadow-md
|
||||
{{ $isToday ? 'bg-blue-50 border-blue-300 shadow-sm' : 'bg-white border-gray-200' }}"
|
||||
style="flex: 1 1 0; min-width: 90px;">
|
||||
<div class="text-center rounded-xl border transition-all
|
||||
{{ $isToday ? 'bg-blue-50 border-blue-200 shadow-md ring-1 ring-blue-100' : ($hasData ? 'bg-white border-gray-200 hover:shadow-sm' : 'bg-gray-50 border-gray-100') }}"
|
||||
style="flex: 1 1 0; min-width: 100px; padding: 14px 10px;">
|
||||
|
||||
{{-- 요일 --}}
|
||||
<div class="text-xs font-semibold mb-0.5
|
||||
{{ $isToday ? 'text-blue-700' : ($isSunday ? 'text-red-500' : ($isSaturday ? 'text-blue-500' : 'text-gray-700')) }}">
|
||||
<div class="text-xs font-bold mb-0.5 tracking-wide
|
||||
{{ $isToday ? 'text-blue-600' : ($isSunday ? 'text-red-500' : ($isSaturday ? 'text-blue-500' : 'text-gray-700')) }}">
|
||||
{{ $isToday ? '오늘' : $dayName }}
|
||||
</div>
|
||||
|
||||
{{-- 날짜 --}}
|
||||
<div class="text-xs {{ $isToday ? 'text-blue-500' : 'text-gray-400' }} mb-2">
|
||||
<div class="text-[11px] {{ $isToday ? 'text-blue-400' : 'text-gray-400' }} mb-3">
|
||||
{{ $dt->format('m.d') }}
|
||||
</div>
|
||||
{{-- 날씨 아이콘 --}}
|
||||
<div class="flex justify-center mb-2">
|
||||
@include('dashboard.partials.weather-icon', ['icon' => $fc['icon'] ?? 'cloud'])
|
||||
</div>
|
||||
{{-- 최고/최저 기온 --}}
|
||||
<div class="space-y-0.5">
|
||||
@if($fc['tmx'] !== null)
|
||||
<div class="text-sm font-bold text-red-500">{{ $fc['tmx'] }}°</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-300">-</div>
|
||||
|
||||
@if($hasData)
|
||||
{{-- 날씨 아이콘 --}}
|
||||
<div class="flex justify-center mb-1.5">
|
||||
@if($icon)
|
||||
@include('dashboard.partials.weather-icon', ['icon' => $icon])
|
||||
@else
|
||||
<div style="width: 40px; height: 40px;"></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 날씨 텍스트 --}}
|
||||
<div class="text-[11px] mb-3 truncate {{ $isPrecip ? 'text-blue-600 font-semibold' : 'text-gray-400' }}">
|
||||
{{ $weatherText ?: '-' }}
|
||||
</div>
|
||||
|
||||
{{-- 기온 바 그래프 --}}
|
||||
@if($tmx !== null && $tmn !== null)
|
||||
<div class="relative mx-auto mb-2" style="height: 6px; border-radius: 3px; background: #f3f4f6;">
|
||||
<div style="
|
||||
position: absolute;
|
||||
left: {{ $barLeft }}%;
|
||||
width: {{ $barWidth }}%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, #60a5fa, #f87171);
|
||||
"></div>
|
||||
</div>
|
||||
@endif
|
||||
@if($fc['tmn'] !== null)
|
||||
<div class="text-xs text-blue-500">{{ $fc['tmn'] }}°</div>
|
||||
@else
|
||||
<div class="text-xs text-gray-300">-</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 최고/최저 기온 --}}
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
@if($tmx !== null)
|
||||
<span class="text-sm font-bold text-red-500">{{ $tmx }}°</span>
|
||||
@else
|
||||
<span class="text-sm text-gray-300">-</span>
|
||||
@endif
|
||||
<span class="text-gray-300">/</span>
|
||||
@if($tmn !== null)
|
||||
<span class="text-sm font-medium text-blue-500">{{ $tmn }}°</span>
|
||||
@else
|
||||
<span class="text-sm text-gray-300">-</span>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
{{-- 데이터 없는 날 --}}
|
||||
<div class="flex flex-col items-center justify-center" style="min-height: 110px;">
|
||||
<svg class="w-6 h-6 text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 12H4M12 4v16"/>
|
||||
</svg>
|
||||
<span class="text-[11px] text-gray-300">준비 중</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user