IDE inspection fixes and global autoformat

This commit is contained in:
Ian Renton
2026-06-20 08:28:11 +01:00
parent 172a31bb18
commit 20966cc7cf
11 changed files with 119 additions and 97 deletions

View File

@@ -462,13 +462,16 @@ As well as being my work, I have also gratefully received feature patches from S
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the `/webassets/img/flags/` directory. The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
`/webassets/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries. This project would not have been possible without these libraries, so many thanks to their developers. The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
This project would not have been possible without these libraries, so many thanks to their developers.
### Third Party Libraries ### Third Party Libraries
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to their own licences and are not covered by the overall licence declared in the `LICENSE` file. A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
their own licences and are not covered by the overall licence declared in the `LICENSE` file. their own licences and are not covered by the overall licence declared in the `LICENSE` file.

View File

@@ -19,6 +19,7 @@ from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything from core.utils import serialize_everything
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.spot_provider import SpotProvider
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
@@ -29,6 +30,7 @@ class APISpotHandler(tornado.web.RequestHandler):
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None self._spots = None
self._web_server_metrics = None self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics, spot_providers=None): def initialize(self, spots, web_server_metrics, spot_providers=None):
@@ -185,7 +187,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit spot to the upstream provider # Submit spot to the upstream provider
provider.submit_spot(spot, upstream_credentials) provider.submit_spot(spot, upstream_credentials)
# Trigger a re-poll after 1 second so the spot appears quickly # Trigger a re-poll after 1 second so the spot appears quickly
threading.Timer(1.0, lambda: provider.force_poll()).start() threading.Timer(1.0, provider.force_poll).start()
except NotImplementedError as e: except NotImplementedError as e:
upstream_warning = str(e) upstream_warning = str(e)
except Exception as e: except Exception as e:
@@ -218,7 +220,7 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, sig): def _find_provider(self, provider_name, sig) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given SIG.""" """Find an enabled provider by name that can submit spots for the given SIG."""
for p in self._spot_providers: for p in self._spot_providers:
@@ -226,7 +228,8 @@ class APISpotHandler(tornado.web.RequestHandler):
return p return p
return None return None
def _verify_recaptcha(self, token): @staticmethod
def _verify_recaptcha(token):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid.""" """Verify a Google reCAPTCHA v2 token. Returns True if valid."""
try: try:

View File

@@ -19,6 +19,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None self._status_data = None
self._web_server_metrics = None self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics, spot_providers=None): def initialize(self, status_data, web_server_metrics, spot_providers=None):
@@ -42,23 +43,27 @@ class APIOptionsHandler(tornado.web.RequestHandler):
if provider.can_submit_spot(sig.name): if provider.can_submit_spot(sig.name):
spot_submit_providers.setdefault(sig.name, []).append(provider.name) spot_submit_providers.setdefault(sig.name, []).append(provider.name)
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
# things that aren't even available.
spot_sources: list = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"])))
alert_sources = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our providers.
if ALLOW_SPOTTING:
spot_sources.append("API")
options = {"bands": BANDS, options = {"bands": BANDS,
"modes": ALL_MODES, "modes": ALL_MODES,
"mode_types": MODE_TYPES, "mode_types": MODE_TYPES,
"sigs": SIGS, "sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available. "spot_sources": spot_sources,
"spot_sources": list( "alert_sources": alert_sources,
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
"alert_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"continents": CONTINENTS, "continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE, "max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING, "spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers} "spot_submit_providers": spot_submit_providers}
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_sources"].append("API")
self.write(json.dumps(options, default=serialize_everything)) self.write(json.dumps(options, default=serialize_everything))
self.set_status(200) self.set_status(200)

View File

@@ -72,12 +72,14 @@ class WebServer:
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}), {"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}), (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}), (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}), (r"/api/v1/options", APIOptionsHandler,
{"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}), (r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}), (r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}), (r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}), (r"/api/v1/spot", APISpotHandler,
{"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}),
] ]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes # If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes

View File

@@ -76,7 +76,8 @@ class ParksNPeaks(HTTPSpotProvider):
user_id = credentials.get("user_id", "") user_id = credentials.get("user_id", "")
api_key = credentials.get("api_key", "") api_key = credentials.get("api_key", "")
if not user_id or not api_key: if not user_id or not api_key:
raise ValueError("Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.") raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
body = { body = {
"actClass": spot.sig or "", "actClass": spot.sig or "",

View File

@@ -95,7 +95,8 @@ class SOTA(HTTPSpotProvider):
"comments": spot.comment or "", "comments": spot.comment or "",
"type": "TEST" # todo replatce with NORMAL/QRT once testing complete "type": "TEST" # todo replatce with NORMAL/QRT once testing complete
} }
headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token, "Content-Type": "application/json"} headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token,
"Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok: if not response.ok:
raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text) raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text)

View File

@@ -14,7 +14,8 @@ class Tiles(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24" SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot" SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia", "VarAC", "Other"] VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia",
"VarAC", "Other"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -82,11 +83,13 @@ class Tiles(HTTPSpotProvider):
headers = {**HTTP_HEADERS, "Content-Type": "application/json"} headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok: if not response.ok:
raise RuntimeError("Tiles on the Air API returned " + str(response.status_code) + ": " + response.text) raise RuntimeError(
"Tiles on the Air API returned " + str(response.status_code) + ": " + response.text)
else: else:
raise RuntimeError("The Tiles on the Air API requires a mode to be set.") raise RuntimeError("The Tiles on the Air API requires a mode to be set.")
else: else:
raise RuntimeError("The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.") raise RuntimeError(
"The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.")
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles' # Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'

View File

@@ -1696,8 +1696,8 @@ components:
items: items:
type: string type: string
example: example:
POTA: [POTA] POTA: [ POTA ]
SOTA: [SOTA] SOTA: [ SOTA, GMA, ParksNPeaks ]
CallLookup: CallLookup:
type: object type: object

View File

@@ -1,24 +1,28 @@
// Credentials schema per provider name. Defines the fields to collect and how to label them. // Credentials schema per provider name. Defines the fields to collect and how to label them.
var PROVIDER_CREDENTIAL_SCHEMAS = { const PROVIDER_CREDENTIAL_SCHEMAS = {
// todo Figure out SOTA authentication // todo Figure out SOTA authentication
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx // see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js // https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid? // Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
// todo type: text/password distinction on text boxes so API keys can be obscured // todo type: text/password distinction on text boxes so API keys can be obscured
"SOTA": [ "SOTA": [
{ key: "access_token", label: "SOTA Access Token", help: "" }, {key: "access_token", label: "SOTA Access Token", help: ""},
{ key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..." } {key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."}
], ],
"ParksNPeaks": [ "ParksNPeaks": [
{ key: "user_id", label: "Parks N Peaks User ID", help: "" }, {key: "user_id", label: "Parks N Peaks User ID", help: ""},
{ key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account." } {key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."}
], ],
"ZLOTA": [ "ZLOTA": [
{ key: "user_id", label: "ZLOTA User ID", help: "" }, {key: "user_id", label: "ZLOTA User ID", help: ""},
{ key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account." } {key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."}
], ],
"Tiles": [ "Tiles": [
{ key: "offline_spot_gateway_pin", label: "Offline Spot Gateway PIN", help: "Get your PIN from your Tiles on the Air account profile." } {
key: "offline_spot_gateway_pin",
label: "Offline Spot Gateway PIN",
help: "Get your PIN from your Tiles on the Air account profile."
}
] ]
}; };
@@ -62,7 +66,7 @@ function loadOptions() {
function loadRecaptcha(siteKey) { function loadRecaptcha(siteKey) {
window._recaptchaSiteKey = siteKey; window._recaptchaSiteKey = siteKey;
if (!document.getElementById('recaptcha-script')) { if (!document.getElementById('recaptcha-script')) {
var script = document.createElement('script'); const script = document.createElement('script');
script.id = 'recaptcha-script'; script.id = 'recaptcha-script';
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha'; script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
script.async = true; script.async = true;
@@ -87,8 +91,8 @@ function updateUpstreamArea() {
return; return;
} }
var sig = $("#sig").val(); const sig = $("#sig").val();
var providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : []; const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
if (providers.length === 0) { if (providers.length === 0) {
$("#upstream-area").hide(); $("#upstream-area").hide();
@@ -99,8 +103,8 @@ function updateUpstreamArea() {
// Update the provider selector // Update the provider selector
$("#upstream-provider-select").empty(); $("#upstream-provider-select").empty();
$.each(providers, function(i, name) { $.each(providers, function (i, name) {
$("#upstream-provider-select").append($('<option>', { value: name, text: name })); $("#upstream-provider-select").append($('<option>', {value: name, text: name}));
}); });
if (providers.length > 1) { if (providers.length > 1) {
@@ -117,7 +121,7 @@ function updateUpstreamArea() {
// Update the credentials button visibility based on selected provider // Update the credentials button visibility based on selected provider
function updateCredentialsButton() { function updateCredentialsButton() {
var providerName = getSelectedUpstreamProvider(); const providerName = getSelectedUpstreamProvider();
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) { if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
$("#upstream-credentials-btn").show(); $("#upstream-credentials-btn").show();
} else { } else {
@@ -127,7 +131,7 @@ function updateCredentialsButton() {
// Get the currently selected upstream provider name // Get the currently selected upstream provider name
function getSelectedUpstreamProvider() { function getSelectedUpstreamProvider() {
var providers = (options && options["spot_submit_providers"] && $("#sig").val()) const providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || []) ? (options["spot_submit_providers"][$("#sig").val()] || [])
: []; : [];
if (providers.length === 0) return null; if (providers.length === 0) return null;
@@ -137,18 +141,18 @@ function getSelectedUpstreamProvider() {
// Show the credentials modal for the currently selected upstream provider // Show the credentials modal for the currently selected upstream provider
function showCredentialsModal() { function showCredentialsModal() {
var providerName = getSelectedUpstreamProvider(); const providerName = getSelectedUpstreamProvider();
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return; if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName]; const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var stored = loadCredentials(providerName); const stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName); $("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty(); $("#credentials-fields").empty();
$.each(schema, function(i, field) { $.each(schema, function (i, field) {
var val = stored[field.key] || ""; const val = stored[field.key] || "";
var html = '<div class="mb-3">'; let html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>'; html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">'; html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) { if (field.help) {
@@ -165,12 +169,12 @@ function showCredentialsModal() {
// Save credentials from the modal to local storage // Save credentials from the modal to local storage
function saveCredentials() { function saveCredentials() {
var providerName = $("#credentials-modal").data("provider"); const providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return; if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName]; const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var creds = {}; const creds = {};
$.each(schema, function(i, field) { $.each(schema, function (i, field) {
creds[field.key] = $("#cred-" + field.key).val(); creds[field.key] = $("#cred-" + field.key).val();
}); });
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds)); localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
@@ -179,7 +183,7 @@ function saveCredentials() {
// Load credentials for a provider from local storage // Load credentials for a provider from local storage
function loadCredentials(providerName) { function loadCredentials(providerName) {
var stored = localStorage.getItem("upstream-credentials-" + providerName); const stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {}; return stored ? JSON.parse(stored) : {};
} }
@@ -237,8 +241,8 @@ function addSpot() {
spot["time"] = moment.utc().valueOf() / 1000.0; spot["time"] = moment.utc().valueOf() / 1000.0;
// Upstream submission // Upstream submission
var submitUpstream = $("#submit-upstream").is(":checked"); const submitUpstream = $("#submit-upstream").is(":checked");
var upstreamProviderName = getSelectedUpstreamProvider(); const upstreamProviderName = getSelectedUpstreamProvider();
if (submitUpstream && upstreamProviderName) { if (submitUpstream && upstreamProviderName) {
if (!sig) { if (!sig) {
showAddSpotError("A SIG must be selected to submit upstream."); showAddSpotError("A SIG must be selected to submit upstream.");
@@ -257,14 +261,14 @@ function addSpot() {
return; return;
} }
var creds = loadCredentials(upstreamProviderName); const creds = loadCredentials(upstreamProviderName);
spot["submit_upstream"] = true; spot["submit_upstream"] = true;
spot["upstream_provider"] = upstreamProviderName; spot["upstream_provider"] = upstreamProviderName;
spot["upstream_credentials"] = creds; spot["upstream_credentials"] = creds;
// Add CAPTCHA token if reCAPTCHA is loaded // Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) { if (window._recaptchaWidgetId !== undefined) {
var token = grecaptcha.getResponse(window._recaptchaWidgetId); const token = grecaptcha.getResponse(window._recaptchaWidgetId);
if (!token) { if (!token) {
showAddSpotError("Please complete the CAPTCHA to submit upstream."); showAddSpotError("Please complete the CAPTCHA to submit upstream.");
return; return;

View File

@@ -539,7 +539,7 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([]); ctx.setLineDash([]);
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having // Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having
// no top "border" gridline // no top "border" gridline
const y30 = scales.y.getPixelForValue(30); const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) { if (y30 >= chartArea.top && y30 <= chartArea.bottom) {