확대하면 길이 사다리처럼 보였다
Oftway 의 발자취 탭은 다닌 길을 15m 칸으로 나누고, 이웃한 두 칸을 잇는 짧은 조각(구간)마다 지나간 횟수를 셉니다. 횟수가 많을수록 선이 굵고 짙어집니다. 반투명 선을 겹쳐 그리면 네이버 지도가 느려져서 이렇게 집계합니다(ADR-0003).
10월 15일에 제 폰으로 가산디지털단지 근처를 확대해 보다가 이상한 모양을 봤습니다. intent 에 남긴 발의는 이렇습니다. "지도를 확대하면 이렇게 네모낳게 잡히는데 이런거 수정해줘." 자주 다닌 길이 한 줄이 아니라 사다리나 그물처럼 보였습니다.
10월 6일부터 15일까지의 폰 기록으로 다시 그려 보니 이유가 보였습니다. 같은 길을 여러 번 지나면 GPS 오차 때문에 그 길이 이웃한 칸 두 줄에 나뉘어 쌓입니다. 두 줄 사이를 비스듬히 가로지른 조각이 사다리의 가로대가 되고, 가로대 사이가 15m 네모 칸으로 남습니다. 회사 앞처럼 짧게 오가며 머문 곳 근처는 칸이 덩어리로 차서 그물이 됐습니다. 기존 선 펴기는 갈림길 칸을 고정하는데, 사다리는 칸마다 갈림길이라 펴지지 않았습니다.
사흘 뒤인 10월 18일에는 같은 동네 가산디지털1로에서 다른 모양을 봤습니다. "이런식으로 뚝뚝 끊기는 선들이 꽤 많아." 한 길이 가는 선과 굵은 토막으로 번갈아 나오고, 그 옆에 한 칸 비켜 나란한 선이 하나 더 있었습니다. 둘 다 GPS 오차가 그림에 그대로 드러난 경우라 오늘은 두 고침을 묶어 정리합니다.
그릴 때만 뼈대를 남긴다
먼저 정한 것은 고칠 자리였습니다. 집계는 건드리지 않고 그리기 직전에만 구간 집합을 가늘게 합니다. 집계를 바꾸면 처리 버전(DERIVE_VERSION)을 올려 원본 점에서 모두 다시 만들어야 하지만, 그리기만 바꾸면 다시 계산이 필요 없고 JS 만 바뀌니 OTA 로 낼 수 있습니다. intent 의 범위와 제약에 이 두 가지가 적혀 있습니다. 그래서 바뀐 곳은 그리기 함수의 첫 줄입니다.
// 두 칸 폭으로 번진 길·그물을 한 칸 폭으로(thin-lines) → 모퉁이 삼각형·꼬투리 떼기
const thinned = thinForDrawing(grid, counts);
counts = pruneForDrawing(grid, thinned.counts, thinned.depth);
thinForDrawing 은 칸 집합을 작은 흑백 그림처럼 다룹니다. 위·아래·왼쪽·오른쪽이 모두 찬 빈 칸 하나짜리 구멍을 먼저 메우고, Zhang–Suen 가늘게 하기로 가장자리 칸을 바뀌는 칸이 없을 때까지 지워 한 칸 폭 뼈대만 남깁니다. 그다음 이웃한 뼈대 칸끼리 구간으로 다시 잇고, 원래 칸마다 가장 가까운 뼈대 칸을 찾아 횟수를 옮깁니다.
Zhang–Suen 을 그대로 쓰지 않은 곳이 하나 있습니다. 칸을 지울 수 있는 이웃 수 하한을 2 가 아니라 3 으로 올렸습니다(Lü–Wang 변형). 코드 주석의 이유는 "두 칸 폭 대각선 띠가 통째로 사라지지 않게"이고, 테스트에도 그 경우가 따로 들어 있습니다. 그래도 2×2 덩어리처럼 통째로 지워지는 경우가 남아서, 그때는 덩어리의 첫 칸 하나를 남기게 했습니다.
짙기를 어떻게 옮길지가 두 번째 판단이었습니다. 뼈대 칸의 무게는 그 칸에 모인 원래 칸들이 닿은 구간 중 가장 큰 횟수로 하고, 뼈대 구간의 횟수는 두 끝 칸 무게 중 작은 값으로 했습니다. 큰 값을 쓰면 옅은 옆길이 짙은 길에 붙는 자리에서 옆길까지 짙어집니다.
메우는 구멍은 한 칸짜리뿐입니다. 30m 넘는 고리는 실제로 블록 하나를 돌아 나온 길일 수 있어서 고리로 남깁니다. 반대로 길 양쪽 인도처럼 15m 안에 붙은 두 길은 하나로 합쳐집니다. 지도 축척에서 선 굵기 3~6pt 가 이미 겹쳐 보이는 거리라 합쳐도 된다고 봤지만, 이 두 가지는 확신이 없어 intent 의 Open questions 에 남겼습니다.
가늘게 한 덩어리의 뼈대는 모서리마다 곁가지를 뻗습니다. 그래서 갈림길에 매달린 1~2칸만 떼던 꼬투리 떼기를, 덩어리 안쪽 깊이가 2 이상인 갈림길에서는 깊이 + 2칸까지 떼도록 넓혔습니다(가지가 셋 이상 남았을 때만).
spec 에 적힌 결과는 이렇습니다. 폰 기록(걷기 구간 10,032개)으로 그린 가산디지털단지 그림에서 사다리와 그물이 사라졌고, 1년 가상 집계(구간 37,456개)의 층 만들기는 73ms 에서 88ms 로 늘었습니다(bun).
한 줄 안에서 짙기를 고른다
선이 한 줄이 되자 다음 문제가 보였습니다. 같은 길을 두 번 지나도 GPS 오차로 15m 칸마다 횟수가 1·2·1·2 로 흔들립니다. 짙기 단계가 몇 칸마다 바뀌니 굵기와 색이 토막토막 났습니다.
이번에도 집계는 두고, 한 줄(이어진 구간 열)의 단계 값에만 1차원 닫기와 열기를 걸었습니다. 닫기(최대 뒤 최소, 반폭 2칸)는 짙은 사이에 낀 4칸 이하의 옅은 틈을 메우고, 열기(최소 뒤 최대, 반폭 1칸)는 2칸 이하의 짙은 토막을 낮춥니다.
export const STEADY = { fill: 2, spike: 1 } as const;
export function steadyDepths(depths: readonly Depth[], fill: number = STEADY.fill, spike: number = STEADY.spike): Depth[] {
if (depths.length === 0) return [];
const pass = (values: readonly Depth[], radius: number, pick: (...v: number[]) => number) =>
values.map((_, i) => pick(...values.slice(Math.max(0, i - radius), i + radius + 1)) as Depth);
// 줄 끝 바깥은 끝 값이 이어지는 것으로 본다(끝의 짧은 꼬리가 안쪽 값으로 메워지지 않게)
const pad = 2 * Math.max(fill, spike);
const padded = [...Array<Depth>(pad).fill(depths[0]), ...depths, ...Array<Depth>(pad).fill(depths[depths.length - 1])];
const closed = pass(pass(padded, fill, Math.max), fill, Math.min);
return pass(pass(closed, spike, Math.min), spike, Math.max).slice(pad, pad + depths.length);
}
기준은 테스트에 그대로 있습니다.
// GPS 오차로 1·2가 번갈아 흔들린 길 → 2 한 줄
expect(steadyDepths([2, 1, 2, 2, 1, 1, 2, 2, 1, 2])).toEqual([2, 2, 2, 2, 2, 2, 2, 2, 2, 2]);
// 5칸 넘는 옅은 구간은 그대로
expect(steadyDepths([2, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2])).toEqual([2, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2]);
// 옅은 길 속 짙은 한두 칸은 낮춘다
expect(steadyDepths([1, 1, 1, 3, 3, 1, 1, 1])).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
옆에 한 줄 더 보이던 선은 차량·대중교통 묶음이었습니다. 이 묶음은 걷기와 따로 가늘게 하고 펴기 때문에, 같은 길이 한 칸 옆에 나란한 둘째 선으로 그려졌습니다. 10월 9일부터 모든 수단을 발자취 색 하나로 그리고 있어서(DESIGN.md 의 one-ink 규칙) 둘은 이중선이나 끊긴 선으로만 보였습니다. 그래서 두 묶음 모두 그릴 구간을 먼저 만든 뒤, 두 끝 칸이 모두 걷기 칸에서 한 칸 안에 있는 차량 구간은 빼게 했습니다(withoutNear). 빼서 새로 끊긴 끝은 뺀 구간을 따라 가장 가까운 걷기 칸까지 최대 4구간 다시 잇습니다. 걷기 선에서 갈라져 나간 찻길이 걷기 선에 닿기 전에 끊겨 보이지 않게 하려는 것입니다.
같은 길이면 걷기 선만 보이지만, 수단을 선으로 나누지 않는 지금 규칙에서는 잃는 정보가 없습니다. 수단별로 선을 다시 나누게 되면 이 규칙도 다시 본다고 intent 에 적어 두었습니다.
spec 의 판정은 폰 기록 가산디지털단지(반경 250m·1.2km) 전후 그림에서 단계 토막과 이중선이 사라지고 선 조각 끝이 크게 줄었다는 것입니다. 층 만들기는 159ms 에서 162ms 로, intent 의 제약("약 160ms 그대로") 안이었습니다. 기준이 thin-lines 때의 88ms 와 다른 이유는 기록에 없습니다.
확인한 것과 남은 것
기록에 남은 검증은 이렇습니다.
- thin-lines(10월 15일, PR #61): 두 칸 폭 띠와 대각선 띠가 고리 없는 한 줄이 되는지, 한 칸 폭 선은 그대로인지, 30m 떨어진 평행한 두 길은 둘로 남는지, 짙은 길 옆 옅은 옆길이 옅은 단계를 지키는지, 그물 덩어리가 곁가지 없는 짧은 선이 되는지, 석 달 가상 집계가 1초 안인지를 테스트로 봤습니다. 폰 기록 전후 그림 비교도 spec 에 있습니다.
- steady-lines(10월 18일, PR #67): 위 단계 열 테스트, 그리고 한 칸 옆 나란한 차량 줄은 빠지고 걷기 선에서 갈라진 차량 길은 걷기 선 바로 옆 칸까지 이어지는지 보는 테스트가 들어갔습니다. 두 변경 모두 DESIGN.md 지도 선 문단에 한 줄씩 덧붙였습니다.
두 plan 의 마지막 줄은 "lint·typecheck·test → PR → 합치기 → OTA"인데, Oftway 는 아직 출시 전이라 사용자에게 나간 업데이트는 없습니다. 기록은 main 에 합쳐진 데까지입니다.
이 방식이 틀릴 수 있는 자리도 그대로 남아 있습니다. 15m 안에 붙은 실제 두 길은 하나로 합쳐집니다. 그물 속에 남는 30m 넘는 고리가 정말 길인지는 폰에서 더 봐야 합니다. 60m 안의 짧은 짙기 차이는 앞뒤에 맞춰 사라지기 때문에, 건물 앞 30m 처럼 실제로 짧게 자주 다닌 길이 옅어 보일 수 있습니다. Oftway 는 따로 기록하지 않아도 다닌 길이 지도에 남고, 자주 다닌 길일수록 짙어지는 발자취 일기입니다. 위치는 폰 밖으로 나가지 않습니다. 지금은 iOS 출시를 준비하고 있습니다.