aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-02-11 16:33:02 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-02-11 16:33:02 +0100
commitb2700b9ef9e34cebc90d669fd53bde91401cae52 (patch)
tree32de878517fe04bd77f4bd40bf55e0f54bfeedae /src
parenta187bcf97de6d043cb663dd973c83cc887665d3a (diff)
Use provided colours in map
Closes #131
Diffstat (limited to 'src')
-rw-r--r--src/frontend/app/components/stop/StopMapModal.tsx91
-rw-r--r--src/frontend/app/data/LineColors.ts64
-rw-r--r--src/frontend/app/data/LinesData.ts256
-rw-r--r--src/frontend/app/routes/stops-$id.tsx8
-rw-r--r--src/frontend/app/utils/colours.ts26
5 files changed, 41 insertions, 404 deletions
diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx
index 688ec2e..30ac63f 100644
--- a/src/frontend/app/components/stop/StopMapModal.tsx
+++ b/src/frontend/app/components/stop/StopMapModal.tsx
@@ -8,9 +8,7 @@ import React, {
} from "react";
import { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
import { Sheet } from "react-modal-sheet";
-import { useApp } from "~/AppContext";
import { AppMap } from "~/components/shared/AppMap";
-import { getLineColour } from "~/data/LineColors";
import type { Stop } from "~/data/StopDataProvider";
import "./StopMapModal.css";
@@ -23,18 +21,15 @@ export interface Position {
export interface ConsolidatedCirculationForMap {
id: string;
- line: string;
- route: string;
currentPosition?: Position;
stopShapeIndex?: number;
- isPreviousTrip?: boolean;
- previousTripShapeId?: string | null;
- schedule?: {
- shapeId?: string | null;
- };
+ colour: string;
+ textColour: string;
shape?: any;
}
+// TODO: Replace `circulations`+`selectedCirculationId` with a single `selectedCirculation` prop
+
interface StopMapModalProps {
stop: Stop;
circulations: ConsolidatedCirculationForMap[];
@@ -50,12 +45,10 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
onClose,
selectedCirculationId,
}) => {
- const { theme } = useApp();
const mapRef = useRef<MapRef | null>(null);
const hasFitBounds = useRef(false);
const userInteracted = useRef(false);
const [shapeData, setShapeData] = useState<any | null>(null);
- const [previousShapeData, setPreviousShapeData] = useState<any | null>(null);
// Filter circulations that have GPS coordinates
const busesWithPosition = useMemo(
@@ -163,7 +156,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
}
} else {
// Current trip: Start from Bus (if not previous), End at User Stop
- if (!previousShapeData && currentPos) {
+ if (currentPos) {
const busIdx = findClosestStopIndex(stops, {
lat: currentPos.latitude,
lon: currentPos.longitude,
@@ -234,7 +227,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
}
};
- addShapePoints(previousShapeData, true);
addShapePoints(shapeData, false);
if (points.length === 0) {
@@ -292,7 +284,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
} as any);
}
} catch {}
- }, [stop, selectedBus, shapeData, previousShapeData]);
+ }, [stop, selectedBus, shapeData]);
// Resize map and fit bounds when modal opens
useEffect(() => {
@@ -324,7 +316,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
hasFitBounds.current = false;
userInteracted.current = false;
setShapeData(null);
- setPreviousShapeData(null);
}
}, [isOpen]);
@@ -332,19 +323,16 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
useEffect(() => {
if (!isOpen || !selectedBus) {
setShapeData(null);
- setPreviousShapeData(null);
return;
}
if (selectedBus.shape) {
setShapeData(selectedBus.shape);
- setPreviousShapeData(null);
handleCenter();
return;
}
setShapeData(null);
- setPreviousShapeData(null);
}, [isOpen, selectedBus]);
if (!selectedBus && busesWithPosition.length === 0) {
@@ -392,58 +380,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
handleCenter();
}}
>
- {/* Previous Shape Layer */}
- {previousShapeData && selectedBus && (
- <Source
- id="prev-route-shape"
- type="geojson"
- data={previousShapeData}
- >
- {/* 1. Black border */}
- <Layer
- id="prev-route-shape-border"
- type="line"
- paint={{
- "line-color": "#000000",
- "line-width": 6,
- "line-opacity": 0.8,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- {/* 2. White background */}
- <Layer
- id="prev-route-shape-white"
- type="line"
- paint={{
- "line-color": "#FFFFFF",
- "line-width": 4,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- {/* 3. Colored dashes */}
- <Layer
- id="prev-route-shape-inner"
- type="line"
- paint={{
- "line-color": getLineColour(selectedBus.line)
- .background,
- "line-width": 4,
- "line-dasharray": [2, 2],
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- </Source>
- )}
-
{/* Shape Layer */}
{shapeData && selectedBus && (
<Source id="route-shape" type="geojson" data={shapeData}>
@@ -451,8 +387,8 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
id="route-shape-border"
type="line"
paint={{
- "line-color": "#000000",
- "line-width": 5,
+ "line-color": selectedBus.textColour,
+ "line-width": 7,
"line-opacity": 0.6,
}}
layout={{
@@ -464,10 +400,8 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
id="route-shape-inner"
type="line"
paint={{
- "line-color": getLineColour(selectedBus.line)
- .background,
- "line-width": 3,
- "line-opacity": 0.7,
+ "line-color": selectedBus.colour,
+ "line-width": 5,
}}
layout={{
"line-cap": "round",
@@ -484,8 +418,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
"circle-color": "#FFFFFF",
"circle-radius": 4,
"circle-stroke-width": 2,
- "circle-stroke-color": getLineColour(selectedBus.line)
- .background,
+ "circle-stroke-color": selectedBus.colour,
}}
/>
</Source>
@@ -557,7 +490,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
>
<path
d="M12 2 L22 22 L12 17 L2 22 Z"
- fill={getLineColour(selectedBus.line).background}
+ fill={selectedBus.colour}
stroke="#000"
strokeWidth="2"
strokeLinejoin="round"
diff --git a/src/frontend/app/data/LineColors.ts b/src/frontend/app/data/LineColors.ts
deleted file mode 100644
index d24d870..0000000
--- a/src/frontend/app/data/LineColors.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-interface LineColorInfo {
- background: string;
- text: string;
-}
-
-const vigoLineColors: Record<string, LineColorInfo> = {
- c1: { background: "rgb(237, 71, 19)", text: "#ffffff" },
- c3d: { background: "rgb(255, 204, 0)", text: "#000000" },
- c3i: { background: "rgb(255, 204, 0)", text: "#000000" },
- l4a: { background: "rgb(0, 153, 0)", text: "#ffffff" },
- l4c: { background: "rgb(0, 153, 0)", text: "#ffffff" },
- l5a: { background: "rgb(0, 176, 240)", text: "#000000" },
- l5b: { background: "rgb(0, 176, 240)", text: "#000000" },
- l6: { background: "rgb(204, 51, 153)", text: "#ffffff" },
- l7: { background: "rgb(150, 220, 153)", text: "#000000" },
- l9b: { background: "rgb(244, 202, 140)", text: "#000000" },
- l10: { background: "rgb(153, 51, 0)", text: "#ffffff" },
- l11: { background: "rgb(226, 0, 38)", text: "#ffffff" },
- l12a: { background: "rgb(106, 150, 190)", text: "#000000" },
- l12b: { background: "rgb(106, 150, 190)", text: "#000000" },
- l13: { background: "rgb(0, 176, 240)", text: "#000000" },
- l14: { background: "rgb(129, 142, 126)", text: "#ffffff" },
- l15a: { background: "rgb(216, 168, 206)", text: "#000000" },
- l15b: { background: "rgb(216, 168, 206)", text: "#000000" },
- l15c: { background: "rgb(216, 168, 168)", text: "#000000" },
- l16: { background: "rgb(129, 142, 126)", text: "#ffffff" },
- l17: { background: "rgb(214, 245, 31)", text: "#000000" },
- l18a: { background: "rgb(212, 80, 168)", text: "#ffffff" },
- l18b: { background: "rgb(212, 80, 168)", text: "#ffffff" },
- l18h: { background: "rgb(212, 80, 168)", text: "#ffffff" },
- l23: { background: "rgb(0, 70, 210)", text: "#ffffff" },
- l24: { background: "rgb(191, 191, 191)", text: "#000000" },
- l25: { background: "rgb(172, 100, 4)", text: "#ffffff" },
- l27: { background: "rgb(112, 74, 42)", text: "#ffffff" },
- l28: { background: "rgb(176, 189, 254)", text: "#000000" },
- l29: { background: "rgb(248, 184, 90)", text: "#000000" },
- l31: { background: "rgb(255, 255, 0)", text: "#000000" },
- a: { background: "rgb(119, 41, 143)", text: "#ffffff" },
- h: { background: "rgb(0, 96, 168)", text: "#ffffff" },
- h1: { background: "rgb(0, 96, 168)", text: "#ffffff" },
- h2: { background: "rgb(0, 96, 168)", text: "#ffffff" },
- h3: { background: "rgb(0, 96, 168)", text: "#ffffff" },
- lzd: { background: "rgb(61, 78, 167)", text: "#ffffff" },
- n1: { background: "rgb(191, 191, 191)", text: "#000000" },
- n4: { background: "rgb(102, 51, 102)", text: "#ffffff" },
- psa1: { background: "rgb(0, 153, 0)", text: "#ffffff" },
- psa4: { background: "rgb(0, 153, 0)", text: "#ffffff" },
- ptl: { background: "rgb(150, 220, 153)", text: "#000000" },
- turistico: { background: "rgb(102, 51, 102)", text: "#ffffff" },
- u1: { background: "rgb(172, 100, 4)", text: "#ffffff" },
- u2: { background: "rgb(172, 100, 4)", text: "#ffffff" },
-};
-
-const defaultLineColor: LineColorInfo = {
- background: "#d32f2f",
- text: "#ffffff",
-};
-
-export function getLineColour(line: string): LineColorInfo {
- let formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
- formattedLine = formattedLine.toLowerCase().trim();
-
- return vigoLineColors[formattedLine.toLowerCase().trim()] ?? defaultLineColor;
-}
diff --git a/src/frontend/app/data/LinesData.ts b/src/frontend/app/data/LinesData.ts
deleted file mode 100644
index cd661b3..0000000
--- a/src/frontend/app/data/LinesData.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-export interface LineInfo {
- lineNumber: string;
- routeName: string;
- scheduleUrl: string;
-}
-
-/**
- * Sourced from https://vitrasa.es/lineas-y-horarios/todas-las-lineas
- *
- Array.from(document.querySelectorAll(".line-information")).map(el => {
- return {
- lineNumber: el.querySelector(".square-info").innerText,
- routeName: el.querySelector(".all-lines-descripcion-prh").innerText,
- scheduleUrl: `https://vitrasa.es/documents/5893389/6130928/${el.querySelector("input[type=checkbox]").value}.pdf`
- }
- });
-
- */
-
-export const VIGO_LINES: LineInfo[] = [
- {
- lineNumber: "C1",
- routeName: "P.América - C. Castillo - P.Sanz - G.Via - P.América",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1.pdf",
- },
- {
- lineNumber: "C3d",
- routeName:
- "Bouzas/Coia - E.Fadrique - Encarnación (dereita) - Pza España - Bouzas/Coia",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/3001.pdf",
- },
- {
- lineNumber: "C3i",
- routeName:
- "Bouzas/Coia - Pza España - Encarnación (esquerda) - E.Fadrique - Bouzas/Coia",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/3002.pdf",
- },
- {
- lineNumber: "4A",
- routeName: "Coia - Camelias - Centro - Aragón",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/4001.pdf",
- },
- {
- lineNumber: "4C",
- routeName: "Coia - Camelias - Centro - M.Garrido - Gregorio Espino",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/4003.pdf",
- },
- {
- lineNumber: "5A",
- routeName: "Navia - Florida - L.Mora - Urzaiz - T.Vigo - Teis",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/5001.pdf",
- },
- {
- lineNumber: "5B",
- routeName: "Navia - Coia - L.Mora - Pi Margall - G.Barbón - Teis",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/5004.pdf",
- },
- {
- lineNumber: "6",
- routeName: "H.Cunqueiro - Beade - Bembrive - Pza. España",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/6.pdf",
- },
- {
- lineNumber: "7",
- routeName: "Zamans/Valladares - Fragoso - P.América - P.España - Centro",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/7.pdf",
- },
- {
- lineNumber: "9B",
- routeName: "Centro - Choróns - San Cristovo - Rabadeira",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/9002.pdf",
- },
- {
- lineNumber: "10",
- routeName:
- "Teis - G.Barbón - Torrecedeira - Av. Atlántida - Samil - Vao - Saiáns",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/10.pdf",
- },
- {
- lineNumber: "11",
- routeName:
- "San Miguel - Vao - P. América - Urzaiz - Ramón Nieto - Grileira",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/11.pdf",
- },
- {
- lineNumber: "12A",
- routeName:
- "Saiáns - Muiños - Castelao - Pi Margall - P.España - H.Meixoeiro",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1201.pdf",
- },
- {
- lineNumber: "12B",
- routeName: "H.Cunqueiro - Castrelos - Camelias - P.España - H.Meixoeiro",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1202.pdf",
- },
- {
- lineNumber: "13",
- routeName: "Navia - Bouzas - Gran Vía - P.España - H.Meixoeiro",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/13.pdf",
- },
- {
- lineNumber: "14",
- routeName: "Gran Vía - Miraflores - Moledo - Chans",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/14.pdf",
- },
- {
- lineNumber: "15A",
- routeName: "Av. Ponte - Choróns - Gran Vía - Castelao - Navia - Samil",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1501.pdf",
- },
- {
- lineNumber: "15B",
- routeName: "Xestoso - Choróns - P.Sanz - Beiramar - Bouzas - Samil",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1506.pdf",
- },
- {
- lineNumber: "15C",
- routeName: "CUVI - Choróns - P.Sanz - Torrecedeira - Bouzas - Samil",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1507.pdf",
- },
- {
- lineNumber: "16",
- routeName: "Coia - Balaídos - Zamora - P.España - Colón - Guixar",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/16.pdf",
- },
- {
- lineNumber: "17",
- routeName: "Matamá/Freixo - Fragoso - Camelias - G.Barbón - Ríos/A Guía",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/17.pdf",
- },
- {
- lineNumber: "18A",
- routeName: "AREAL/COLÓN - SÁRDOMA/POULEIRA",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/18.pdf",
- },
- {
- lineNumber: "18B",
- routeName: "URZAIZ / P.ESPAÑA - POULEIRA",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1801.pdf",
- },
- {
- lineNumber: "18H",
- routeName: "URZAIZ / P. ESPAÑA - H. ALV. CUNQUEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/1802.pdf",
- },
- {
- lineNumber: "23",
- routeName: "M. ECHEGARAY - Balaídos - Gran Vía - Choróns - Gregorio Espino",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/23.pdf",
- },
- {
- lineNumber: "24",
- routeName: "Poulo - Vía Norte - Colón - Guixar",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/24.pdf",
- },
- {
- lineNumber: "25",
- routeName: "PZA. ESPAÑA – SABAXÁNS / CAEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/25.pdf",
- },
- {
- lineNumber: "27",
- routeName: "BEADE (C. CULTURAL) – RABADEIRA",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/27.pdf",
- },
- {
- lineNumber: "28",
- routeName: "VIGOZOO - SAN PAIO - BOUZAS",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/28.pdf",
- },
- {
- lineNumber: "29",
- routeName: "FRAGOSELO / S. ANDRÉS – PZA. ESPAÑA",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/29.pdf",
- },
- {
- lineNumber: "31",
- routeName: "SAN LOURENZO – HOSP. MEIXOEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/31.pdf",
- },
- {
- lineNumber: "A",
- routeName: "ARENAL – PORTO / UNIVERSIDADE",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/8.pdf",
- },
- {
- lineNumber: "H",
- routeName: "NAVIA - BOUZAS - HOSPITAL ALVARO CUNQUEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/104.pdf",
- },
- {
- lineNumber: "H1",
- routeName: "POLICARPO SANZ – HOSPITAL ÁLVARO CUNQUEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/101.pdf",
- },
- {
- lineNumber: "H2",
- routeName: "GREGORIO ESPINO – HOSPITAL ÁLVARO CUNQU",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/102.pdf",
- },
- {
- lineNumber: "H3",
- routeName: "GARCÍA BARBÓN – HOSPITAL ÁLVARO CUNQUEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/105.pdf",
- },
- {
- lineNumber: "LZD",
- routeName: "STELLANTIS - ALV. CUNQUEIRO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/751.pdf",
- },
- {
- lineNumber: "N1",
- routeName: "SAMIL – BUENOS AIRES",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/30.pdf",
- },
- {
- lineNumber: "N4",
- routeName: "NAVIA - G. ESPINO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/3305.pdf",
- },
- {
- lineNumber: "PSA1",
- routeName: "STELLANTIS - G.BARBON",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/301.pdf",
- },
- {
- lineNumber: "PSA4",
- routeName: "STELLANTIS - G. BARBON",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/4004.pdf",
- },
- {
- lineNumber: "PTL",
- routeName: "PARQUE TECNOLÓXICO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/304.pdf",
- },
- {
- lineNumber: "TUR",
- routeName: "TURISTICO",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/500.pdf",
- },
- {
- lineNumber: "U1",
- routeName: "LANZADEIRA PZA. AMÉRICA – UNIVERSIDADE",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/201.pdf",
- },
- {
- lineNumber: "U2",
- routeName: "LANZADEIRA PZA. DE ESPAÑA – UNIVERSIDADE",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/202.pdf",
- },
- {
- lineNumber: "VTS",
- routeName: "CABRAL - BASE",
- scheduleUrl: "https://vitrasa.es/documents/5893389/6130928/3010.pdf",
- },
-];
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index d4301cc..bff8c7f 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -11,6 +11,7 @@ import { PullToRefresh } from "~/components/PullToRefresh";
import { StopHelpModal } from "~/components/stop/StopHelpModal";
import { StopMapModal } from "~/components/stop/StopMapModal";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { formatHex } from "~/utils/colours";
import StopDataProvider from "../data/StopDataProvider";
import "./stops-$id.css";
@@ -231,13 +232,10 @@ export default function Estimates() {
}}
circulations={(data ?? []).map((a) => ({
id: getArrivalId(a),
- line: a.route.shortName,
- route: a.headsign.destination,
currentPosition: a.currentPosition ?? undefined,
stopShapeIndex: a.stopShapeIndex ?? undefined,
- schedule: {
- shapeId: undefined,
- },
+ colour: formatHex(a.route.colour),
+ textColour: formatHex(a.route.textColour),
shape: a.shape,
}))}
isOpen={isMapModalOpen}
diff --git a/src/frontend/app/utils/colours.ts b/src/frontend/app/utils/colours.ts
new file mode 100644
index 0000000..aa939f7
--- /dev/null
+++ b/src/frontend/app/utils/colours.ts
@@ -0,0 +1,26 @@
+// TODO: Standardise this shit server-side
+export function formatHex(hex: string, poundSign = true): string {
+ if (hex.length === 6) {
+ return (poundSign ? "#" : "") + hex;
+ } else if (hex.length === 3) {
+ return (
+ (poundSign ? "#" : "") +
+ hex
+ .split("")
+ .map((c) => c + c)
+ .join("")
+ );
+ } else if (hex.length === 7 && hex.startsWith("#")) {
+ return poundSign ? hex : hex.substring(1);
+ } else if (hex.length === 4 && hex.startsWith("#")) {
+ return poundSign
+ ? hex
+ : hex
+ .substring(1)
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ } else {
+ throw new Error("Invalid hex color format");
+ }
+}