Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts
T
eomsangdonandClaude Opus 5 d14222242a refactor(B04/B05/공통): 지도 색상 토큰화, 배수유역 패널 i18n, 소개 페이지 단계명 통일
- ui_template_theme.css에 2D 지도 벡터 팔레트(--map-*) 33종을 유일한 정의처로 등록하고,
  캔버스에서 CSS 변수를 읽는 공통 유틸 ui_template_palette.ts(themeColor)를 신설.
  값은 한 번만 읽어 캐시하고 data-theme 변경 시 비운다(다크 전환 대응).
- B04 지도·유역 오버레이·흐름 화살표와 B05 배수유역도·유역선 편집·배관 마커의
  하드코딩 색상을 전부 토큰 조회로 교체. 계획선 색·굵기, 후광색은 공용 함수로 일원화.
- B05 배수유역 패널의 사용자 문구를 전부 ui_locales로 이관(제목, 레이어 토글 5종,
  도구 버튼 5종, 툴팁, 상태 문구 8종, 유역 제원 표기). B04 유역 분석 오버레이의
  버튼·갈래 토글·진행/실패 안내도 함께 전환. 관리자 진단용 결과 판독문은 원문 유지.
- A02 프로그램 소개의 6단계 제목과 A01 히어로 문구의 단계 나열을 진행단계 이름
  (전처리/종단설계/횡단설계/상세설계/수량산출/설계도서)과 일치시킴.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:35:42 +09:00

642 lines
22 KiB
TypeScript

import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다.
// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다.
export type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
export type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
export type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
export type MarkerKind = "dot" | "x";
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
/** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */
export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
/**
* 계획선(노선) 표기 색 — B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다.
* 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다.
* 색 값 자체는 `ui_template_theme.css`의 `--map-route`가 유일한 정의처다.
*/
export const routeLineColor = (): string => themeColor("--map-route", "#f97316");
/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */
export const ROUTE_LINE_WIDTH = 2.4;
/**
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
minY: number;
maxX: number;
maxY: number;
/** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
};
export type PreparedLayer = {
features: PreparedFeature[];
};
/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */
export type Normalizer = {
lonMin: number;
latMin: number;
lonRange: number;
latRange: number;
aspect: number;
};
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
export type MapRect = {
x: number;
y: number;
width: number;
height: number;
};
/** 프레임 단위 뷰 상태. */
export type ViewState = {
width: number;
height: number;
scale: number;
offsetX: number;
offsetY: number;
mapRect: MapRect;
};
export function createNormalizer(meta: VWorldMeta): Normalizer {
return {
lonMin: meta.lon_min,
latMin: meta.lat_min,
lonRange: meta.lon_max - meta.lon_min || 1,
latRange: meta.lat_max - meta.lat_min || 1,
aspect: meta.width_meters / Math.max(meta.height_meters, 1),
};
}
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
if (!meta) return { x: 0, y: 0, width, height };
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return {
x: (width - mapWidth) / 2,
y: (height - mapHeight) / 2,
width: mapWidth,
height: mapHeight,
};
}
/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다
* (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */
export const ROUTE_VIEW_MARGIN_M = 200;
/** 평면 좌표(m) 범위. */
export interface PlanBounds {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
}
/**
* 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙).
*
* 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다.
* 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다).
*/
export function computeRouteView(
meta: VWorldMeta | null,
route: PlanBounds | null,
viewportWidth: number,
viewportHeight: number,
marginM: number = ROUTE_VIEW_MARGIN_M,
): { scale: number; offsetX: number; offsetY: number } {
if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 };
const mapRect = computeMapRect(meta, viewportWidth, viewportHeight);
const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2;
const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2;
const scale = Math.max(
Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight),
1,
);
const centerX = (route.x_min + route.x_max) / 2;
const centerY = (route.y_min + route.y_max) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
return {
scale,
offsetX: -(baseX - viewportWidth / 2) * scale,
offsetY: -(baseY - viewportHeight / 2) * scale,
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
}
/**
* 프레임당 1회 계산하는 어파인 계수.
* base = mapRect.x + norm * mapRect.width
* screen = center + (base - center) * scale + offset
* = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset)
*/
type Affine = { ax: number; bx: number; ay: number; by: number };
function affineOf(view: ViewState): Affine {
const centerX = view.width / 2;
const centerY = view.height / 2;
return {
ax: view.mapRect.width * view.scale,
bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX,
ay: view.mapRect.height * view.scale,
by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY,
};
}
/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */
const LOD_PX = 0.75;
function drawLineParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
): void {
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
const tolerance = LOD_PX / affine.ax;
for (const part of feature.parts) {
const coords = part.coords;
if (coords.length < 4) continue;
const weights = part.weights;
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
context.beginPath();
let started = false;
for (let i = 0; i < coords.length; i += 2) {
if (weights && weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (started) context.lineTo(x, y);
else {
context.moveTo(x, y);
started = true;
}
}
if (!started) continue;
if (part.closed) context.closePath();
context.stroke();
}
}
function drawPointParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
marker: MarkerKind,
): void {
for (const part of feature.parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
continue;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
}
}
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
const minX = feature.minX * affine.ax + affine.bx;
const maxX = feature.maxX * affine.ax + affine.bx;
const minY = feature.minY * affine.ay + affine.by;
const maxY = feature.maxY * affine.ay + affine.by;
return !(
maxX < -margin ||
minX > view.width + margin ||
maxY < -margin ||
minY > view.height + margin
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
): void {
const affine = affineOf(view);
for (const feature of layer.features) {
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
for (const feature of layer.features) {
if (feature.labelText === null) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
context.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
}
}
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */
export type FilledRing = {
ring: ReadonlyArray<readonly [number, number]>;
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
label?: string;
};
/**
* lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다.
* 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다.
*/
export function drawFilledRing(
context: CanvasRenderingContext2D,
entry: FilledRing,
normalizer: Normalizer,
view: ViewState,
color: string,
): void {
if (entry.ring.length < 3) return;
const affine = affineOf(view);
let sumX = 0;
let sumY = 0;
context.beginPath();
entry.ring.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
sumX += x;
sumY += y;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = color;
context.fill();
context.strokeStyle = color;
context.lineWidth = 1.6;
context.stroke();
if (!entry.label) return;
// 면적 중심(정점 평균)에 번호를 원형 배지로 얹는다.
const centerX = sumX / entry.ring.length;
const centerY = sumY / entry.ring.length;
context.beginPath();
context.arc(centerX, centerY, 11, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.strokeStyle = haloColor();
context.lineWidth = 1.5;
context.stroke();
context.font = "600 12px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = themeColor("--map-label-text", "#1f2937");
context.fillText(entry.label, centerX, centerY);
}
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
export function drawUpstreamLines(
context: CanvasRenderingContext2D,
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
normalizer: Normalizer,
view: ViewState,
): void {
if (lines.length === 0) return;
const affine = affineOf(view);
context.save();
context.setLineDash([]);
context.lineWidth = 4;
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = upstreamLineColor();
lines.forEach((line) => {
if (line.length < 2) return;
context.beginPath();
line.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
});
context.restore();
}
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
export function drawRidgeRing(
context: CanvasRenderingContext2D,
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): void {
if (ring.length < 3) return;
const affine = affineOf(view);
context.beginPath();
ring.forEach(([lon, lat], index) => {
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
const x = nx * affine.ax + affine.bx;
const y = ny * affine.ay + affine.by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.save();
context.strokeStyle = themeColor("--map-basin-outline", "#92400e");
context.lineWidth = 1.8;
context.setLineDash([7, 4]);
context.stroke();
context.restore();
}