feat(B06): 유토곡선 balloon 축소·곡선 회피 배치·드래그 이동 + 양방향 하이라이트
도형이 크고 남는 공간을 못 찾는다는 지적 반영. - balloon을 두 줄 상한(번호·물량 / L·장비)으로 줄이고 여백·육각 노치 축소. EA/RR/BR은 툴팁으로 옮김 - 배치를 세로 밀기에서 가로·세로 링 탐색으로 바꾸고 curveYAt으로 곡선까지 회피. 못 피하면 곡선 조건만 버리고 재탐색(안 그리는 것보다 낫다) - balloon을 끌어 옮길 수 있고 지시선 끝점이 따라간다. 옮긴 자리는 세션에 남고 더블클릭이면 자동 배치로 복귀. 지시선은 balloon 밖에 둬야 같이 끌려가지 않는다 - 띠 면과 balloon을 같은 짝으로 묶어 어느 쪽을 눌러도 둘 다 강조된다 검증: 4개 크기에서 NaN 0건, balloon 11개 중 곡선 겹침 0~1개(이전엔 회피 없음), 도형·클릭 짝·드래그·세션 저장·더블클릭 복귀 확인. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,11 @@ export interface BalanceLayerBox {
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
/**
|
||||
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
|
||||
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
|
||||
*/
|
||||
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
|
||||
}
|
||||
|
||||
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
|
||||
@@ -48,16 +53,54 @@ const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
|
||||
dump_truck: "rect",
|
||||
};
|
||||
|
||||
const LINE_HEIGHT = 11;
|
||||
const PAD_X = 8;
|
||||
const PAD_Y = 5;
|
||||
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
|
||||
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
|
||||
const LINE_HEIGHT = 10;
|
||||
const PAD_X = 5;
|
||||
const PAD_Y = 3;
|
||||
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
|
||||
const NOTCH = 8;
|
||||
const NOTCH = 5;
|
||||
/** balloon과 곡선 사이 최소 틈(px). */
|
||||
const CURVE_CLEARANCE = 5;
|
||||
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
|
||||
const MIN_BAND_WIDTH_PX = 34;
|
||||
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
|
||||
const MIN_BAND_THICKNESS_PX = 9;
|
||||
|
||||
/**
|
||||
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]). 다시 그릴 때마다 자동 배치로
|
||||
* 되돌아가면 옮긴 보람이 없으므로 세션에 남긴다(패널 높이·접힘과 같은 규칙).
|
||||
*/
|
||||
const BALLOON_OFFSET_KEY = "b06:balance-balloon-offset";
|
||||
|
||||
function readOffsets(): Map<number, [number, number]> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(BALLOON_OFFSET_KEY);
|
||||
if (!raw) return new Map();
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") return new Map();
|
||||
return new Map(
|
||||
Object.entries(parsed as Record<string, [number, number]>).map(([key, value]) => [
|
||||
Number(key),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function writeOffsets(offsets: Map<number, [number, number]>): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
BALLOON_OFFSET_KEY,
|
||||
JSON.stringify(Object.fromEntries([...offsets].map(([key, value]) => [String(key), value]))),
|
||||
);
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
|
||||
function textWidth(value: string): number {
|
||||
let width = 0;
|
||||
@@ -140,35 +183,118 @@ function overlaps(a: PlacedBox, b: PlacedBox): boolean {
|
||||
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
|
||||
}
|
||||
|
||||
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
|
||||
function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
|
||||
const band = box.curveYAt?.(candidate.left, candidate.right);
|
||||
if (!band) return false;
|
||||
return !(
|
||||
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹치지 않는 세로 자리를 찾는다. 선호 방향으로 한 칸씩 밀어 보고, 상자를 벗어나면 반대쪽을
|
||||
* 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다.
|
||||
* 빈자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
|
||||
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
|
||||
*
|
||||
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
|
||||
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
|
||||
*/
|
||||
function findSlot(
|
||||
placed: PlacedBox[],
|
||||
centerX: number,
|
||||
preferredX: number,
|
||||
preferredY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
box: BalanceLayerBox,
|
||||
upward: boolean,
|
||||
): number {
|
||||
const step = height + 4;
|
||||
const half = height / 2;
|
||||
for (const direction of upward ? [-1, 1] : [1, -1]) {
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
const centerY = preferredY + direction * step * attempt;
|
||||
if (centerY - half < box.top || centerY + half > box.bottom) break;
|
||||
const candidate: PlacedBox = {
|
||||
left: centerX - width / 2,
|
||||
right: centerX + width / 2,
|
||||
top: centerY - half,
|
||||
bottom: centerY + half,
|
||||
};
|
||||
if (!placed.some((entry) => overlaps(entry, candidate))) return centerY;
|
||||
): { x: number; y: number } {
|
||||
const stepY = height + 3;
|
||||
const stepX = width * 0.55;
|
||||
const clampX = (value: number): number =>
|
||||
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
|
||||
const candidates: Array<{ x: number; y: number }> = [];
|
||||
for (let ring = 0; ring < 7; ring += 1) {
|
||||
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
|
||||
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
|
||||
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
|
||||
}
|
||||
}
|
||||
}
|
||||
return preferredY;
|
||||
for (const avoidCurve of [true, false]) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
|
||||
const rect: PlacedBox = {
|
||||
left: candidate.x - width / 2,
|
||||
right: candidate.x + width / 2,
|
||||
top: candidate.y - height / 2,
|
||||
bottom: candidate.y + height / 2,
|
||||
};
|
||||
if (placed.some((entry) => overlaps(entry, rect))) continue;
|
||||
if (avoidCurve && hitsCurve(box, rect)) continue;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return { x: clampX(preferredX), y: preferredY };
|
||||
}
|
||||
|
||||
/**
|
||||
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
|
||||
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
|
||||
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
|
||||
*/
|
||||
function attachBalloonDrag(
|
||||
balloon: SVGGElement,
|
||||
leader: SVGLineElement,
|
||||
svg: SVGSVGElement,
|
||||
key: number,
|
||||
offsets: Map<number, [number, number]>,
|
||||
home: { x: number; y: number },
|
||||
): void {
|
||||
let start: { x: number; y: number; dx: number; dy: number } | null = null;
|
||||
const scale = (): number => {
|
||||
const rendered = svg.getBoundingClientRect().width;
|
||||
const viewBox = svg.viewBox.baseVal?.width || rendered;
|
||||
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
|
||||
};
|
||||
const apply = (dx: number, dy: number): void => {
|
||||
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
|
||||
leader.setAttribute("x2", String(home.x + dx));
|
||||
leader.setAttribute("y2", String(home.y + dy));
|
||||
};
|
||||
balloon.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const current = offsets.get(key) ?? [0, 0];
|
||||
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
|
||||
balloon.setPointerCapture(event.pointerId);
|
||||
balloon.classList.add("is-dragging");
|
||||
});
|
||||
balloon.addEventListener("pointermove", (event) => {
|
||||
if (!start) return;
|
||||
const factor = scale();
|
||||
const dx = start.dx + (event.clientX - start.x) * factor;
|
||||
const dy = start.dy + (event.clientY - start.y) * factor;
|
||||
offsets.set(key, [dx, dy]);
|
||||
apply(dx, dy);
|
||||
});
|
||||
const finish = (event: PointerEvent): void => {
|
||||
if (!start) return;
|
||||
start = null;
|
||||
balloon.releasePointerCapture(event.pointerId);
|
||||
balloon.classList.remove("is-dragging");
|
||||
writeOffsets(offsets);
|
||||
};
|
||||
balloon.addEventListener("pointerup", finish);
|
||||
balloon.addEventListener("pointercancel", finish);
|
||||
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
|
||||
balloon.addEventListener("dblclick", (event) => {
|
||||
event.stopPropagation();
|
||||
offsets.delete(key);
|
||||
apply(0, 0);
|
||||
writeOffsets(offsets);
|
||||
});
|
||||
}
|
||||
|
||||
/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */
|
||||
@@ -249,46 +375,55 @@ function appendBandGeometry(
|
||||
/** 띠 balloon. 도형이 운반수단이고, 자리가 좁으면 줄을 줄여서라도 물량은 남긴다. */
|
||||
function appendBandBalloon(
|
||||
group: SVGGElement,
|
||||
svg: SVGSVGElement,
|
||||
block: HaulBlock,
|
||||
band: HaulBand,
|
||||
box: BalanceLayerBox,
|
||||
placed: PlacedBox[],
|
||||
offsets: Map<number, [number, number]>,
|
||||
): SVGGElement | null {
|
||||
if (box.x(block.to_m) - box.x(block.from_m) < MIN_BAND_WIDTH_PX) return null;
|
||||
|
||||
const equipment = equipmentLabel(band.equipment);
|
||||
const room = box.bottom - box.top;
|
||||
// 화면 balloon은 도면과 달리 두 줄이 상한이다. EA/RR/BR·블록 번호는 툴팁이 맡는다.
|
||||
const lines = [`${band.index} · ${compactVolume(band.volume_m3)}㎥`];
|
||||
if (room >= 110) {
|
||||
if (room >= 120) {
|
||||
lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`);
|
||||
}
|
||||
if (room >= 150) {
|
||||
lines.push(
|
||||
`EA ${compactVolume(band.ea_m3)} · RR ${compactVolume(band.rr_m3)} · BR ${compactVolume(band.br_m3)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH;
|
||||
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
|
||||
const baseY = box.y(band.level_base_m3);
|
||||
const apexY = box.y(band.level_apex_m3);
|
||||
const anchorX = box.x(block.apex_m);
|
||||
const anchorY = (baseY + apexY) / 2;
|
||||
const upward = block.direction === "forward";
|
||||
const preferred = Math.min(
|
||||
Math.max((baseY + apexY) / 2, box.top + height / 2),
|
||||
box.bottom - height / 2,
|
||||
const home = findSlot(
|
||||
placed,
|
||||
anchorX,
|
||||
Math.min(Math.max(anchorY, box.top + height / 2), box.bottom - height / 2),
|
||||
width,
|
||||
height,
|
||||
box,
|
||||
upward,
|
||||
);
|
||||
const centerX = Math.min(
|
||||
Math.max(box.x(block.apex_m), box.left + width / 2),
|
||||
box.right - width / 2,
|
||||
);
|
||||
const centerY = findSlot(placed, centerX, preferred, width, height, box, upward);
|
||||
placed.push({
|
||||
left: centerX - width / 2,
|
||||
right: centerX + width / 2,
|
||||
top: centerY - height / 2,
|
||||
bottom: centerY + height / 2,
|
||||
left: home.x - width / 2,
|
||||
right: home.x + width / 2,
|
||||
top: home.y - height / 2,
|
||||
bottom: home.y + height / 2,
|
||||
});
|
||||
|
||||
// 지시선은 balloon **밖**에 둔다 — 안에 두면 balloon을 옮길 때 지시선도 같이 끌려가
|
||||
// 어느 띠의 것인지 가리키지 못한다.
|
||||
const leader = svgElement("line", {
|
||||
x1: anchorX,
|
||||
y1: anchorY,
|
||||
x2: home.x,
|
||||
y2: home.y,
|
||||
class: "b06-balance__leader",
|
||||
});
|
||||
const balloon = svgElement("g", { class: "b06-balance__balloon" });
|
||||
const title = svgElement("title");
|
||||
title.textContent =
|
||||
@@ -296,27 +431,27 @@ function appendBandBalloon(
|
||||
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(band.volume_m3)}㎥ · ` +
|
||||
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(band.haul_distance_m)}m · ` +
|
||||
`EA ${compactVolume(band.ea_m3)} / RR ${compactVolume(band.rr_m3)} / BR ${compactVolume(band.br_m3)}㎥`;
|
||||
// 지시선 — balloon이 밀려났을 때 어느 띠의 것인지 잇는다.
|
||||
balloon.append(
|
||||
title,
|
||||
svgElement("line", {
|
||||
x1: box.x(block.apex_m),
|
||||
y1: (baseY + apexY) / 2,
|
||||
x2: centerX,
|
||||
y2: centerY,
|
||||
class: "b06-balance__leader",
|
||||
}),
|
||||
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", centerX, centerY, width, height),
|
||||
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", home.x, home.y, width, height),
|
||||
...lines.map((line, index) =>
|
||||
svgText(line, {
|
||||
x: centerX,
|
||||
y: centerY - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
|
||||
x: home.x,
|
||||
y: home.y - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
|
||||
"text-anchor": "middle",
|
||||
class: "b06-balance__balloon-text",
|
||||
}),
|
||||
),
|
||||
);
|
||||
group.append(balloon);
|
||||
group.append(leader, balloon);
|
||||
|
||||
const saved = offsets.get(band.index);
|
||||
if (saved) {
|
||||
balloon.setAttribute("transform", `translate(${saved[0]} ${saved[1]})`);
|
||||
leader.setAttribute("x2", String(home.x + saved[0]));
|
||||
leader.setAttribute("y2", String(home.y + saved[1]));
|
||||
}
|
||||
attachBalloonDrag(balloon, leader, svg, band.index, offsets, home);
|
||||
return balloon;
|
||||
}
|
||||
|
||||
@@ -388,26 +523,30 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box));
|
||||
}
|
||||
const placed: PlacedBox[] = [];
|
||||
const offsets = readOffsets();
|
||||
for (const block of plan.blocks) {
|
||||
for (const band of block.bands)
|
||||
balloons.push(appendBandBalloon(group, block, band, box, placed));
|
||||
balloons.push(appendBandBalloon(group, svg, block, band, box, placed, offsets));
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
for (const face of faces) face.classList.remove("is-active");
|
||||
for (const balloon of balloons) balloon?.classList.remove("is-active");
|
||||
};
|
||||
faces.forEach((face, index) => {
|
||||
face.addEventListener("click", (event) => {
|
||||
// 띠 면과 balloon은 **같은 짝**이라 어느 쪽을 눌러도 둘 다 켜진다(2026-08-02 사용자 지시).
|
||||
const bind = (target: SVGGElement | null, index: number): void => {
|
||||
target?.addEventListener("click", (event) => {
|
||||
// 카드·측점 선택까지 올라가면 그래프가 다시 그려져 방금 켠 강조가 사라진다.
|
||||
event.stopPropagation();
|
||||
const already = face.classList.contains("is-active");
|
||||
const already = faces[index].classList.contains("is-active");
|
||||
clearAll();
|
||||
if (already) return;
|
||||
face.classList.add("is-active");
|
||||
faces[index].classList.add("is-active");
|
||||
balloons[index]?.classList.add("is-active");
|
||||
});
|
||||
});
|
||||
};
|
||||
faces.forEach((face, index) => bind(face, index));
|
||||
balloons.forEach((balloon, index) => bind(balloon, index));
|
||||
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
|
||||
svg.addEventListener("click", clearAll);
|
||||
svg.append(group);
|
||||
|
||||
@@ -284,6 +284,22 @@ export function createMassHaulChart(
|
||||
const zeroY = y(0);
|
||||
const visible = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
|
||||
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위. 말풍선·balloon 배치가 함께 쓴다. */
|
||||
const curveYAt = (fromPx: number, toPx: number): { top: number; bottom: number } | null => {
|
||||
let lowest = Number.POSITIVE_INFINITY;
|
||||
let highest = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of visible) {
|
||||
for (const point of entry.result.points) {
|
||||
const px = x(point.chainage_m);
|
||||
if (px < fromPx || px > toPx) continue;
|
||||
const py = y(point.cumulative_volume_m3);
|
||||
lowest = Math.min(lowest, py);
|
||||
highest = Math.max(highest, py);
|
||||
}
|
||||
}
|
||||
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
|
||||
};
|
||||
|
||||
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
|
||||
const banded = visible[0];
|
||||
if (banded) {
|
||||
@@ -375,6 +391,7 @@ export function createMassHaulChart(
|
||||
right: widthPx - LONG_PAD.right,
|
||||
top: MASS_PAD_TOP,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,21 +414,7 @@ export function createMassHaulChart(
|
||||
right: widthPx - LONG_PAD.right,
|
||||
top: MASS_PAD_TOP,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt: (fromPx, toPx) => {
|
||||
// 말풍선이 놓일 가로 구간에서 곡선들이 실제로 지나는 세로 범위.
|
||||
let lowest = Number.POSITIVE_INFINITY;
|
||||
let highest = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of visible) {
|
||||
for (const point of entry.result.points) {
|
||||
const px = x(point.chainage_m);
|
||||
if (px < fromPx || px > toPx) continue;
|
||||
const py = y(point.cumulative_volume_m3);
|
||||
lowest = Math.min(lowest, py);
|
||||
highest = Math.max(highest, py);
|
||||
}
|
||||
}
|
||||
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
|
||||
},
|
||||
curveYAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -203,16 +203,25 @@
|
||||
fill: color-mix(in srgb, var(--color-warning) 22%, transparent);
|
||||
}
|
||||
|
||||
/* balloon은 끌어 옮길 수 있다(더블클릭이면 자동 배치로 복귀) — 커서로 그 사실을 알린다. */
|
||||
.b06-balance__balloon {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.b06-balance__balloon.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.b06-balance__balloon.is-dragging > .b06-balance__balloon-shape {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* 눌린 띠의 balloon은 테두리를 굵혀 어느 띠의 값인지 바로 짚이게 한다. */
|
||||
.b06-balance__balloon.is-active > .b06-balance__balloon-shape {
|
||||
fill: color-mix(in srgb, var(--color-warning) 20%, var(--color-surface-raised));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b06-balance__balloon.is-active > .b06-balance__leader {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */
|
||||
.b06-balance__riser {
|
||||
stroke: var(--color-warning);
|
||||
|
||||
Reference in New Issue
Block a user