geoaccess app

This commit is contained in:
Alessio Ganzarolli 2025-08-02 04:50:15 +02:00
parent 02fa10c2d5
commit 51f507811c
18 changed files with 577 additions and 129 deletions

19
app/package-lock.json generated
View file

@ -19,10 +19,12 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"jotai": "^2.12.5", "jotai": "^2.12.5",
"lucide-react": "^0.536.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-router": "^5.3.4", "react-router": "^5.3.4",
"react-router-dom": "^5.3.4" "react-router-dom": "^5.3.4",
"tinycolor2": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "7.4.2", "@capacitor/cli": "7.4.2",
@ -8484,6 +8486,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.536.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@ -10814,6 +10825,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz",

View file

@ -23,10 +23,12 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"jotai": "^2.12.5", "jotai": "^2.12.5",
"lucide-react": "^0.536.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-router": "^5.3.4", "react-router": "^5.3.4",
"react-router-dom": "^5.3.4" "react-router-dom": "^5.3.4",
"tinycolor2": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "7.4.2", "@capacitor/cli": "7.4.2",

View file

@ -1,30 +1,26 @@
import './App.css'; import "./App.css";
import './theme/variables.css'; import "./theme/variables.css";
import '@ionic/react/css/core.css'; import "@ionic/react/css/core.css";
import '@ionic/react/css/display.css'; import "@ionic/react/css/display.css";
import '@ionic/react/css/flex-utils.css'; import "@ionic/react/css/flex-utils.css";
import '@ionic/react/css/float-elements.css'; import "@ionic/react/css/float-elements.css";
import '@ionic/react/css/normalize.css'; import "@ionic/react/css/normalize.css";
import '@ionic/react/css/padding.css'; import "@ionic/react/css/padding.css";
import '@ionic/react/css/palettes/dark.system.css'; import "@ionic/react/css/palettes/dark.system.css";
import '@ionic/react/css/structure.css'; import "@ionic/react/css/structure.css";
import '@ionic/react/css/text-alignment.css'; import "@ionic/react/css/text-alignment.css";
import '@ionic/react/css/text-transformation.css'; import "@ionic/react/css/text-transformation.css";
import '@ionic/react/css/typography.css'; import "@ionic/react/css/typography.css";
import { IonApp, IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs, setupIonicReact } from '@ionic/react'; import { IonApp, setupIonicReact } from "@ionic/react";
import { IonReactRouter } from '@ionic/react-router'; import { IonReactRouter } from "@ionic/react-router";
import { list, musicalNote, search } from 'ionicons/icons'; import { useAtom } from "jotai";
import { useAtom } from 'jotai'; import { useEffect, useState } from "react";
import { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom';
import { currentTrackAtom } from './atoms'; import { currentTrackAtom } from "./atoms";
import { mocks } from './mocks'; import { mocks } from "./mocks";
import Tab1 from './pages/Tab1'; import Tab from "./pages/Tab3";
import Tab2 from './pages/Tab2'; import LocationAccessChecker from "./hooks/GeoAccess";
import Tab3 from './pages/Tab3';
import Tab from './pages/Tab3';
/* Core CSS required for Ionic components to work properly */ /* Core CSS required for Ionic components to work properly */
/* Basic CSS for apps built with Ionic */ /* Basic CSS for apps built with Ionic */
@ -35,15 +31,21 @@ import Tab from './pages/Tab3';
setupIonicReact(); setupIonicReact();
const App: React.FC = () => { const App: React.FC = () => {
const [current, setCurrent] = useAtom(currentTrackAtom) const [current, setCurrent] = useAtom(currentTrackAtom);
const [accessGranted, setAccessGranted] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
setCurrent(mocks.nowPlaying) setCurrent(mocks.nowPlaying);
}, []) }, []);
// if (accessGranted === false) {
// console.log("access denied.");
// return null;
// }
return ( return (
<IonApp> <IonApp>
{/* {current && <Background imageUrl={current.coverUrl} color={current.color}/>} */} <LocationAccessChecker onAccessChecked={setAccessGranted} />
<IonReactRouter> <IonReactRouter>
<Tab /> <Tab />
</IonReactRouter> </IonReactRouter>

3
app/src/api_endpoints.ts Normal file
View file

@ -0,0 +1,3 @@
export const API_BASE_URL = "https://fbbb261497e3.ngrok-free.app";
export const GEO_ACCESS_API = `${API_BASE_URL}/geo-access/real`;
export const MONGO_DB_CODA_API = `${API_BASE_URL}/queue`

View file

@ -1,5 +1,4 @@
.now-playing-cover { .now-playing-cover {
margin-top: 25px;
margin-left: 25px; margin-left: 25px;
width: calc(100% - 50px); width: calc(100% - 50px);
height: auto; height: auto;
@ -15,7 +14,7 @@
left: 0; left: 0;
right: 0; right: 0;
height: 80px; height: 80px;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.4)); /* background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.4)); */
pointer-events: none; pointer-events: none;
} }
@ -35,7 +34,7 @@
.now-playing-artist { .now-playing-artist {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--text-shy); color: var(--text-supporting);
margin-top: 0; margin-top: 0;
opacity: 0.8; opacity: 0.8;
} }

View file

@ -1,6 +1,6 @@
import { IonContent } from "@ionic/react"; import './NowPlayingTab.css';
import React, { useEffect, useState } from "react";
import "./NowPlayingTab.css"; import React, { useEffect, useState } from 'react';
export interface NowPlayingData { export interface NowPlayingData {
coverUrl: string; coverUrl: string;

View file

@ -13,6 +13,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px;
} }
h1.queue { h1.queue {

View file

@ -1,13 +1,25 @@
import './Queue.css'; import "./Queue.css";
import { IonButton, IonContent, IonHeader, IonIcon, IonModal, IonSearchbar, IonTitle, IonToolbar } from '@ionic/react'; import {
import { add } from 'ionicons/icons'; IonButton,
import React, { useState } from 'react'; IonContent,
IonHeader,
IonModal,
IonSearchbar,
IonTitle,
IonToolbar,
} from "@ionic/react";
import React, { useState } from "react";
import { ArrowBigUp, Plus } from "lucide-react";
interface NowPlayingDiscProps { interface NowPlayingDiscProps {
coverUrl: string; coverUrl: string;
title: string; title: string;
artist: string; artist: string;
upvoted?: boolean;
upvotes?: number;
} }
interface QueueProps { interface QueueProps {
@ -18,20 +30,27 @@ const Queue: React.FC<QueueProps> = ({ songs }) => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const handleUpVote = () => {};
return ( return (
<div className="queue-container"> <div className="queue-container">
<div className="queue-header"> <div className="queue-header">
<h1 className="queue">Up next</h1> <h1 className="queue">Up next</h1>
<IonButton <Plus
fill="clear" size={24}
onClick={() => setShowModal(true)} color="white"
className="icon-button" strokeWidth={2}
> onClick={() => {
<IonIcon icon={add} /> setShowModal(true);
</IonButton> }}
/>
</div> </div>
{songs.map((song, index) => ( {songs.map((song, index) => {
const isUpvoted = song.upvoted ?? false;
const votes = song.upvotes ?? 0;
return (
<div className="song-item" key={index}> <div className="song-item" key={index}>
<img <img
className="cover" className="cover"
@ -42,8 +61,33 @@ const Queue: React.FC<QueueProps> = ({ songs }) => {
<div className="title">{song.title}</div> <div className="title">{song.title}</div>
<div className="artist">{song.artist}</div> <div className="artist">{song.artist}</div>
</div> </div>
<div
className="upvote-container"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginLeft: "auto",
cursor: "pointer",
}}
onClick={handleUpVote}
>
<span
style={{ fontWeight: "600", color: "#444", userSelect: "none" }}
>
{votes}
</span>
<ArrowBigUp
size={24}
strokeWidth={2.5}
color={isUpvoted ? "#ff6600" : "#999"}
fill={isUpvoted ? "#ff6600" : "none"}
/>
</div> </div>
))} </div>
);
})}
<IonModal isOpen={showModal} onDidDismiss={() => setShowModal(false)}> <IonModal isOpen={showModal} onDidDismiss={() => setShowModal(false)}>
<IonHeader> <IonHeader>

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonList,
IonItem,
IonLabel,
IonInput,
IonButton,
IonSpinner,
IonText,
} from '@ionic/react';
import { useCoda } from '../hooks/useCoda';
const TestCoda: React.FC = () => {
const {
records,
loading,
error,
fetchRecords,
addRecord,
deleteRecord,
updateVoti,
} = useCoda();
// Stati per i nuovi record da aggiungere
const [title, setTitle] = useState('');
const [artista, setArtista] = useState('');
const [coverUrl, setCoverUrl] = useState('');
const [color, setColor] = useState('');
const handleAdd = () => {
if (!title || !artista || !coverUrl || !color) return alert('Completa tutti i campi');
addRecord({ title, artista, coverUrl, color, voti: 0 });
setTitle('');
setArtista('');
setCoverUrl('');
setColor('');
};
return (
<IonPage>
<IonContent className="ion-padding">
<h1>Test UseCoda</h1>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 10 }}>
<IonSpinner name="crescent" />
</div>
)}
{error && (
<IonText color="danger">
<p>{error}</p>
</IonText>
)}
<IonButton expand="block" onClick={fetchRecords} disabled={loading}>
Ricarica Record
</IonButton>
<h2>Aggiungi Nuovo Record</h2>
<IonInput
placeholder="Title"
value={title}
onIonChange={(e) => setTitle(e.detail.value!)}
/>
<IonInput
placeholder="Artista"
value={artista}
onIonChange={(e) => setArtista(e.detail.value!)}
/>
<IonInput
placeholder="Cover URL"
value={coverUrl}
onIonChange={(e) => setCoverUrl(e.detail.value!)}
/>
<IonInput
placeholder="Color (es. #ff0000)"
value={color}
onIonChange={(e) => setColor(e.detail.value!)}
/>
<IonButton expand="block" onClick={handleAdd} disabled={loading}>
Aggiungi
</IonButton>
<h2>Lista Record</h2>
<IonList>
{records.length === 0 && !loading && <p>Nessun record disponibile</p>}
{records.map((r) => (
<IonItem key={r.id} style={{ borderLeft: `5px solid ${r.color}` }}>
<IonLabel>
<h3>{r.title}</h3>
<p>Artista: {r.artista}</p>
<img
src={r.coverUrl}
alt={r.title}
style={{ width: 50, height: 50, objectFit: 'cover', marginBottom: 5 }}
/>
<p>Voti: {r.voti}</p>
</IonLabel>
<IonButton size="small" onClick={() => updateVoti(r.id, 1)} disabled={loading}>
+1
</IonButton>
<IonButton size="small" onClick={() => updateVoti(r.id, -1)} disabled={loading}>
-1
</IonButton>
<IonButton color="danger" size="small" onClick={() => deleteRecord(r.id)} disabled={loading}>
Elimina
</IonButton>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default TestCoda;

135
app/src/hooks/GeoAccess.tsx Normal file
View file

@ -0,0 +1,135 @@
import React, { useEffect, useState } from "react";
import {
IonPage,
IonContent,
IonSpinner,
IonText,
IonButton,
} from "@ionic/react";
import { GEO_ACCESS_API } from "../api_endpoints";
const MAX_ACCEPTABLE_ACCURACY = 100; // metri
const LocationAccessChecker: React.FC<{
onAccessChecked?: (granted: boolean) => void;
}> = ({ onAccessChecked }) => {
const [loading, setLoading] = useState(true);
const [accessGranted, setAccessGranted] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const [accuracy, setAccuracy] = useState<number | null>(null);
useEffect(() => {
const checkLocationAccess = async () => {
if (!navigator.geolocation) {
setError("Geolocation non supportata dal browser");
setAccessGranted(false);
setLoading(false);
onAccessChecked?.(false);
return;
}
navigator.geolocation.getCurrentPosition(
async (position) => {
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
const positionAccuracy = position.coords.accuracy;
console.log("Lat:", latitude);
console.log("Lng:", longitude);
console.log("Accuracy (m):", positionAccuracy);
setAccuracy(positionAccuracy);
if (positionAccuracy > MAX_ACCEPTABLE_ACCURACY) {
setError(
`Precisione troppo bassa: ${positionAccuracy.toFixed(1)} metri`
);
setAccessGranted(false);
setLoading(false);
onAccessChecked?.(false);
return;
}
const coordinates = {
coords: [latitude, longitude],
};
try {
const response = await fetch(GEO_ACCESS_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(coordinates),
});
if (!response.ok) {
throw new Error("Errore nella risposta dal server");
}
const data = await response.json();
if (data.success === true) {
setAccessGranted(true);
onAccessChecked?.(true);
} else {
setAccessGranted(false);
onAccessChecked?.(false);
}
} catch (e) {
setError("Errore di rete o server");
setAccessGranted(false);
onAccessChecked?.(false);
} finally {
setLoading(false);
}
},
(err) => {
setError(`Errore geolocalizzazione: ${err.message}`);
setAccessGranted(false);
setLoading(false);
onAccessChecked?.(false);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000,
}
);
};
checkLocationAccess();
}, [onAccessChecked]);
if (loading) {
return (
<IonPage>
<IonContent className="ion-padding" fullscreen>
<IonSpinner name="crescent" />
<IonText>Verifica posizione in corso...</IonText>
</IonContent>
</IonPage>
);
}
if (accessGranted === false) {
return (
<IonPage>
<IonContent className="ion-padding" fullscreen>
<IonText color="danger">
<h2>Accesso non consentito</h2>
<p>La tua posizione non permette luso dellapp.</p>
{error && <p>Errore: {error}</p>}
{accuracy !== null && (
<p>Precisione posizione: {accuracy.toFixed(1)} metri</p>
)}
</IonText>
<IonButton onClick={() => window.location.reload()}>
Riprova
</IonButton>
</IonContent>
</IonPage>
);
}
return null; // accesso consentito, niente da mostrare
};
export default LocationAccessChecker;

113
app/src/hooks/useCoda.tsx Normal file
View file

@ -0,0 +1,113 @@
import { useState, useEffect } from "react";
import { MONGO_DB_CODA_API } from "../api_endpoints";
const API_URL = MONGO_DB_CODA_API;
export interface Record {
id: string;
titolo: string;
coverUrl: string;
artista: string;
color: string;
voti: number;
}
export const useCoda = () => {
const [records, setRecords] = useState<Record[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchJson = async (input: RequestInfo, init?: RequestInit) => {
const res = await fetch(input, init);
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Errore fetch");
}
return res.json();
};
const fetchRecords = async () => {
setLoading(true);
try {
const response = await fetchJson(`${API_URL}/read`);
if (response.success) {
setRecords(response.data);
setError(null);
} else {
throw new Error("Errore nella risposta del server");
}
} catch (e: any) {
setError(e.message || "Errore fetch");
} finally {
setLoading(false);
}
};
const addRecord = async (record: Omit<Record, "id">) => {
try {
const body = JSON.stringify(record);
const response = await fetchJson(`${API_URL}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (response.success) {
setRecords((prev) => [...prev, response.data]);
setError(null);
} else {
throw new Error("Errore aggiunta record");
}
} catch (e: any) {
setError(e.message || "Errore add");
}
};
const deleteRecord = async (id: string) => {
try {
const response = await fetchJson(`${API_URL}/delete/${id}`, {
method: "DELETE",
});
if (response.success) {
setRecords((prev) => prev.filter((r) => r.id !== id));
setError(null);
} else {
throw new Error("Errore eliminazione record");
}
} catch (e: any) {
setError(e.message || "Errore delete");
}
};
const updateVoti = async (id: string, delta: 1 | -1) => {
try {
const response = await fetchJson(`${API_URL}/vote/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ increment: delta === 1 }),
});
if (response.success) {
const updatedItem: Record = response.data;
setRecords((prev) => prev.map((r) => (r.id === id ? updatedItem : r)));
setError(null);
} else {
throw new Error("Errore aggiornamento voti");
}
} catch (e: any) {
setError(e.message || "Errore update voti");
}
};
useEffect(() => {
fetchRecords();
}, []);
return {
records,
loading,
error,
fetchRecords,
addRecord,
deleteRecord,
updateVoti,
};
};

6
app/src/pages/Tab.css Normal file
View file

@ -0,0 +1,6 @@
.gradient-background {
--background: none !important;
/* height: 100%; */
padding-top: 25px;
padding-bottom: 25px;
}

View file

@ -1,6 +0,0 @@
.tab-1-container {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}

View file

@ -1,30 +0,0 @@
import "./Tab1.css";
import { IonContent, IonPage } from "@ionic/react";
import { useAtom } from "jotai";
import { currentTrackAtom } from "../atoms";
import NowPlayingDisc from "../components/NowPlayingDisc";
const Tab1: React.FC = () => {
const [current] = useAtom(currentTrackAtom);
return (
<IonPage>
<IonContent fullscreen className="transparent-content">
<div className="tab-1-container">
{current && (
<NowPlayingDisc
coverUrl={current.coverUrl}
title={current.title}
artist={current.artist}
color={current.color}
/>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Tab1;

View file

View file

@ -1,17 +0,0 @@
import Queue from '../components/Queue';
import { mocks } from '../mocks';
import './Tab2.css';
import { IonContent, IonPage } from '@ionic/react';
const Tab2: React.FC = () => {
return (
<IonPage>
<IonContent fullscreen className="transparent-content">
<Queue songs={mocks.queue} />
</IonContent>
</IonPage>
);
};
export default Tab2;

View file

@ -1,27 +1,41 @@
import { IonContent, IonPage } from '@ionic/react'; import { IonContent, IonPage } from "@ionic/react";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import React from 'react'; import React from "react";
import { currentTrackAtom } from '../atoms'; import { currentTrackAtom } from "../atoms";
import NowPlayingTab from '../components/NowPlayingTab'; import NowPlayingTab from "../components/NowPlayingTab";
import Queue from '../components/Queue'; import Queue from "../components/Queue";
import { mocks } from '../mocks'; import { mocks } from "../mocks";
import "./Tab.css";
import { darkenAndSaturate } from "../utils";
import TestCoda from "../components/testCodaDbMongo";
const Tab: React.FC = () => { const Tab: React.FC = () => {
const [current] = useAtom(currentTrackAtom); const [current] = useAtom(currentTrackAtom);
const now = Date.now(); const now = Date.now();
if (!current) return null;
const darkerColor = darkenAndSaturate(current.color, 40, 100);
return ( return (
<IonPage> <IonPage>
<IonContent fullscreen> <IonContent fullscreen className="">
<div
className="gradient-background"
style={{
background: `linear-gradient(to bottom, ${current.color}, ${darkerColor})`,
}}
>
{current && ( {current && (
<NowPlayingTab <NowPlayingTab
data={current} data={current}
startTime={now - 15000} // es. iniziata 15 sec fa startTime={now - 15000}
duration={180000} // 3 minuti duration={180000}
/> />
)} )}
</div>
<TestCoda />
<Queue songs={mocks.queue} /> <Queue songs={mocks.queue} />
</IonContent> </IonContent>
</IonPage> </IonPage>

46
app/src/utils.tsx Normal file
View file

@ -0,0 +1,46 @@
export function darkenAndSaturate(color: string, darkenPercent = 20, saturatePercent = 20): string {
const ctx = document.createElement("canvas").getContext("2d");
if (!ctx) return color;
ctx.fillStyle = color;
const rgb = ctx.fillStyle; // browser normalizza il colore
ctx.fillStyle = rgb;
const computed = ctx.fillStyle;
const temp = document.createElement("div");
temp.style.color = computed;
document.body.appendChild(temp);
const rgbStr = getComputedStyle(temp).color;
document.body.removeChild(temp);
const match = rgbStr.match(/rgba?\((\d+), (\d+), (\d+)/);
if (!match) return color;
let [r, g, b] = match.slice(1).map(Number);
// Convert to HSL
const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm), min = Math.min(rNorm, gNorm, bNorm);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rNorm: h = (gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0); break;
case gNorm: h = (bNorm - rNorm) / d + 2; break;
case bNorm: h = (rNorm - gNorm) / d + 4; break;
}
h /= 6;
}
// Modify saturation and lightness
s = Math.min(1, s + saturatePercent / 100);
l = Math.max(0, l - darkenPercent / 100);
const hDeg = Math.round(h * 360);
const sPerc = Math.round(s * 100);
const lPerc = Math.round(l * 100);
return `hsl(${hDeg}, ${sPerc}%, ${lPerc}%)`;
}