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
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy