Skip to content

Instantly share code, notes, and snippets.

@kiding
Created June 16, 2025 04:43
Show Gist options
  • Save kiding/5233f0ffe179d36b16dfc9f3cb908a31 to your computer and use it in GitHub Desktop.
Save kiding/5233f0ffe179d36b16dfc9f3cb908a31 to your computer and use it in GitHub Desktop.
기상청 초단기예측 Scriptable 스크립트
// 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