Created
June 16, 2025 04:43
-
-
Save kiding/5233f0ffe179d36b16dfc9f3cb908a31 to your computer and use it in GitHub Desktop.
기상청 초단기예측 Scriptable 스크립트
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: deep-gray; icon-glyph: magic; | |
/** | |
* 그래프 Y축 최대. 단위: mm/h | |
* @see https://youtu.be/WnWCoLJKvCU | |
*/ | |
const MAX_blnd = 2; | |
/** | |
* 위젯 갱신 시점 결정 | |
* @see https://docs.scriptable.app/listwidget/#refreshafterdate | |
*/ | |
const REFRESH_RATE = 5 * 60 * 1000; | |
const currentDate = new Date(); | |
const refreshDate = new Date(+currentDate + REFRESH_RATE); | |
/** | |
* 현재 위치 GPS 위경도 -> 기상청 격자 | |
* iOS 정책에 따라 백그라운드에서 현재 위치 권한이 없을 수 있음 | |
* 위치를 알 수 없어도 기존 위치 그대로 사용할 수 있도록 수집과 분리 | |
* @see https://www.weather.go.kr/w/resources/js/fn.js | |
*/ | |
try { | |
Location.setAccuracyToHundredMeters(); | |
const { latitude: lat, longitude: lon } = await Location.current(); | |
console.log({ lat, lon }); | |
const RE = 6371.00877; // 지구 반경(km) | |
const GRID = 5.0; // 격자 간격(km) | |
const SLAT1 = 30.0; // 투영 위도1(degree) | |
const SLAT2 = 60.0; // 투영 위도2(degree) | |
const OLON = 126.0; // 기준점 경도(degree) | |
const OLAT = 38.0; // 기준점 위도(degree) | |
const XO = 43; // 기준점 X좌표(GRID) | |
const YO = 136; // 기준점 Y좌표(GRID) | |
const DEGRAD = Math.PI / 180.0; | |
const re = RE / GRID; | |
const slat1 = SLAT1 * DEGRAD; | |
const slat2 = SLAT2 * DEGRAD; | |
const olon = OLON * DEGRAD; | |
const olat = OLAT * DEGRAD; | |
let sn = | |
Math.tan(Math.PI * 0.25 + slat2 * 0.5) / | |
Math.tan(Math.PI * 0.25 + slat1 * 0.5); | |
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); | |
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); | |
sf = (Math.pow(sf, sn) * Math.cos(slat1)) / sn; | |
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5); | |
ro = (re * sf) / Math.pow(ro, sn); | |
let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5); | |
ra = (re * sf) / Math.pow(ra, sn); | |
let theta = lon * DEGRAD - olon; | |
if (theta > Math.PI) theta -= 2.0 * Math.PI; | |
if (theta < -Math.PI) theta += 2.0 * Math.PI; | |
theta *= sn; | |
const [x, y] = [ | |
Math.floor(ra * Math.sin(theta) + XO + 0.5), | |
Math.floor(ro - ra * Math.cos(theta) + YO + 0.5), | |
]; | |
console.log({ x, y }); | |
/** | |
* 키체인에 위치 정보 저장 | |
* @see https://docs.scriptable.app/keychain/ | |
*/ | |
Keychain.set("location", JSON.stringify({ lat, lon, x, y })); | |
} catch (e) { | |
console.error(e.message); | |
} | |
let didFetchSucceed = true; | |
try { | |
/** 수집 */ | |
/** | |
* 키체인에서 위치 정보 불러오기 | |
* @see https://docs.scriptable.app/keychain/ | |
*/ | |
const { | |
lat = 0, | |
lon = 0, | |
x = 0, | |
y = 0, | |
} = Keychain.contains("location") ? JSON.parse(Keychain.get("location")) : {}; | |
/** | |
* 현재 지역 행정동 조회 | |
* @see https://www.weather.go.kr/w/rest/zone/find/dong.do?x=${x}&y=${y}&lat=${lat}&lon=${lon}&lang=kor | |
*/ | |
const dongUrl = `https://www.weather.go.kr/w/rest/zone/find/dong.do?x=${x}&y=${y}&lat=${lat}&lon=${lon}&lang=kor`; | |
const dongReq = new Request(dongUrl); | |
const dongRes = await dongReq.loadString(); | |
console.log({ dongRes }); | |
const [{ name: dongName }] = JSON.parse(dongRes); | |
console.log({ dongUrl, dongName }); | |
/** | |
* 기상청 강수 초단기예측 조회 | |
* @see https://vapi.kma.go.kr/capi/url/vs_blnd_blnd_pt_txt1.php?tm=YYYYMMDDHHMI&lat=&lon=&x=61&y=125&disp=V&type=BLND | |
*/ | |
const tmFormatter = new DateFormatter(); | |
tmFormatter.dateFormat = "yyyyMMddHHmm"; | |
const gmtDate = new Date(new Date() - 9 * 60 * 60 * 1000); | |
const tm = tmFormatter.string(gmtDate); | |
console.log({ tm }); | |
const prcpUrl = `https://vapi.kma.go.kr/capi/url/vs_prcp_blnd_pt_txt1.php?tm=${tm}&x=${x}&y=${y}&disp=V&type=BLND`; | |
const prcpReq = new Request(prcpUrl); | |
let prcpRes = await prcpReq.loadString(); | |
prcpRes = prcpRes.replace(/datareaderror:.+$/, ''); | |
console.log({ prcpRes }); | |
const { | |
data: { resultList: prcpList }, | |
} = JSON.parse(prcpRes); | |
console.log({ prcpUrl, prcpList }); | |
/** | |
* 키체인에 수집 데이터 저장 | |
* @see https://docs.scriptable.app/keychain/ | |
*/ | |
Keychain.set("dongName", dongName); | |
Keychain.set("prcpList", JSON.stringify(prcpList)); | |
} catch (e) { | |
console.error(e); | |
didFetchSucceed = false; | |
Keychain.set("lastError", e.message); | |
} finally { | |
/** 위젯 그리기 */ | |
/** | |
* 키체인에서 수집 데이터 불러오기 | |
* @see https://docs.scriptable.app/keychain/ | |
*/ | |
const dongName = Keychain.contains("dongName") | |
? Keychain.get("dongName") | |
: "(알 수 없음)"; | |
const prcpList = Keychain.contains("prcpList") | |
? JSON.parse(Keychain.get("prcpList")).slice(0, 35) | |
: []; | |
/** | |
* 위젯 그리기 | |
* @see https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/ | |
* @see https://developer.apple.com/design/human-interface-guidelines/widgets/overview/design/ | |
*/ | |
/* 이미지 그리기 시작 */ | |
const ctxWidth = 321; | |
const ctxHeight = 148; | |
const ctx = new DrawContext(); | |
ctx.size = new Size(ctxWidth, ctxHeight); | |
ctx.respectScreenScale = true; | |
ctx.setFillColor(Color.black()); | |
ctx.fillRect(new Rect(0, 0, ctxWidth, ctxHeight)); | |
/* 상단 행정동 + 갱신 시간 텍스트 */ | |
const topHeight = 14; | |
const topMarginTop = 7; | |
const topMarginLeft = 12; | |
const topMarginRight = 12; | |
const topPaddingBottom = 5; | |
console.log({ topHeight, topMarginLeft, topMarginRight, topPaddingBottom }); | |
ctx.setFont(Font.boldSystemFont(topHeight - topPaddingBottom)); | |
ctx.setTextColor(Color.white()); | |
ctx.setTextAlignedLeft(); | |
ctx.drawTextInRect( | |
`📍${dongName}`, | |
new Rect(topMarginLeft, topMarginTop, ctxWidth, topHeight) | |
); | |
const topDateFormatter = new DateFormatter(); | |
topDateFormatter.dateFormat = "HH:mm"; | |
ctx.setTextAlignedRight(); | |
ctx.drawTextInRect( | |
(didFetchSucceed ? "✅" : "❌") + | |
" " + | |
topDateFormatter.string(currentDate) + | |
" → " + | |
topDateFormatter.string(refreshDate), | |
new Rect(0, topMarginTop, ctxWidth - topMarginRight, topHeight) | |
); | |
/* 하단 시간 텍스트 */ | |
const bottomHeight = 11; | |
const bottomMarginBottom = 7; | |
const bottomPaddingTop = 2; | |
console.log({ bottomHeight, bottomMarginBottom, bottomPaddingTop }); | |
ctx.setFont(Font.boldRoundedSystemFont(bottomHeight - bottomPaddingTop)); | |
ctx.setTextColor(Color.white()); | |
ctx.setTextAlignedLeft(); | |
const blndWidth = Math.ceil(ctxWidth / prcpList.length); | |
const hourY = | |
ctxHeight - bottomMarginBottom - bottomHeight + bottomPaddingTop; | |
prcpList.forEach(([val], i) => { | |
const hour = val.substring(11, 13); | |
const minute = val.substring(14, 16); | |
if (minute != "00") { | |
return; | |
} | |
const hourX = blndWidth * i; | |
console.log({ val, hourX, hourY }); | |
ctx.drawTextInRect(hour, new Rect(hourX, hourY, ctxWidth, bottomHeight)); | |
}); | |
/* 중간 강수 그래프 */ | |
const midHeight = | |
ctxHeight - topHeight - topMarginTop - bottomHeight - bottomMarginBottom; | |
const blndHeightUnit = midHeight / MAX_blnd; | |
prcpList.forEach(([, , val], i) => { | |
const blnd = Math.min(val, MAX_blnd); | |
const blndX = blndWidth * i; | |
const blndHeight = Math.ceil(blnd * blndHeightUnit); | |
const blndY = topHeight + topMarginTop + midHeight - blndHeight; | |
console.log({ blnd, blndX, blndY, blndWidth, blndHeight }); | |
ctx.setFillColor(Color.blue()); | |
ctx.fillRect(new Rect(blndX, blndY, blndWidth, blndHeight)); | |
}); | |
/* 위젯 생성, 다음 갱신 시간 설정 */ | |
const widget = new ListWidget(); | |
widget.backgroundImage = ctx.getImage(); | |
widget.refreshAfterDate = refreshDate; | |
if (!config.runsInWidget) { | |
widget.presentMedium(); | |
} | |
Script.setWidget(widget); | |
} | |
Script.complete(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment