Compare commits

...

8 Commits

Author SHA1 Message Date
Matt Walsh
262ea15468 5.17.0 2025-05-13 21:13:37 -05:00
Matt Walsh
5d2e5a6d9c update versions 2025-05-13 14:59:54 -05:00
Matt Walsh
992258d3ce css/sass cleanup 2025-05-13 13:59:12 -05:00
Matt Walsh
b031934022 autocomplete working 2025-05-13 13:57:50 -05:00
Matt Walsh
4cc2312ffd Merge branch 'main' into remove-jquery 2025-05-12 13:35:01 -05:00
Matt Walsh
ce99fc16e7 5.16.6 2025-05-12 10:48:12 -05:00
Matt Walsh
97f96d4091 fix missing states in station list 2025-05-12 10:47:43 -05:00
Matt Walsh
c7eb56f60c non-jquery autocomplete, needs more keyboard integration 2024-10-21 23:03:34 -05:00
19 changed files with 23440 additions and 47044 deletions

View File

@@ -2,8 +2,7 @@
"env": {
"browser": true,
"es6": true,
"node": true,
"jquery": true
"node": true
},
"extends": [
"airbnb-base"
@@ -12,7 +11,8 @@
"TravelCities": "readonly",
"RegionalCities": "readonly",
"StationInfo": "readonly",
"SunCalc": "readonly"
"SunCalc": "readonly",
"NoSleep": "readonly"
},
"parserOptions": {
"ecmaVersion": 2023,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,61 +9,59 @@ import chunk from './chunk.mjs';
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// immediately invoked function so we can access async/await
const start = async () => {
// chunk the list of states
const chunkStates = chunk(states, 5);
// chunk the list of states
const chunkStates = chunk(states, 1);
// store output
const output = {};
// store output
const output = {};
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
stateChunk.forEach(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
do {
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
// write the output
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2));
}
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
// eslint-disable-next-line no-await-in-loop
await Promise.allSettled(stateChunk.map(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
let round = 0;
do {
console.log(`Getting: ${state}-${round}`);
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
round += 1;
// write the output
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2));
}
});
}
};
// immediately invoked function allows access to async
await start();
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
}
}));
}

View File

@@ -62,8 +62,6 @@ const compressJsData = () => src(jsSourcesData)
.pipe(dest(RESOURCES_PATH));
const jsVendorSources = [
'server/scripts/vendor/auto/jquery.js',
'server/scripts/vendor/jquery.autocomplete.min.js',
'server/scripts/vendor/auto/nosleep.js',
'server/scripts/vendor/auto/swiped-events.js',
'server/scripts/vendor/auto/suncalc.js',

View File

@@ -9,7 +9,6 @@ const vendorFiles = [
'./node_modules/luxon/build/es6/luxon.js',
'./node_modules/luxon/build/es6/luxon.js.map',
'./node_modules/nosleep.js/dist/NoSleep.js',
'./node_modules/jquery/dist/jquery.js',
'./node_modules/suncalc/suncalc.js',
'./node_modules/swiped-events/src/swiped-events.js',
];

737
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.16.5",
"version": "5.17.0",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
@@ -37,8 +37,6 @@
"gulp-s3-upload": "^1.7.3",
"gulp-sass": "^6.0.0",
"gulp-terser": "^2.0.0",
"jquery": "^3.6.0",
"jquery-touchswipe": "^1.6.19",
"luxon": "^3.0.0",
"nosleep.js": "^0.12.0",
"sass": "^1.54.0",

View File

@@ -1,6 +0,0 @@
module.exports = {
rules: {
// unicorn
'unicorn/numeric-separators-style': 0,
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import {
import { round2 } from './modules/utils/units.mjs';
import { parseQueryString } from './modules/share.mjs';
import settings from './modules/settings.mjs';
import AutoComplete from './modules/autocomplete.mjs';
document.addEventListener('DOMContentLoaded', () => {
init();
@@ -49,13 +50,12 @@ const init = () => {
window.addEventListener('resize', fullScreenResizeCheck);
fullScreenResizeCheck.wasFull = false;
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('keydown', (key) => { if (key.code === 'Enter') formSubmit(); });
document.querySelector('#btnGetLatLng').addEventListener('click', () => formSubmit());
document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit());
document.addEventListener('keydown', documentKeydown);
document.addEventListener('touchmove', (e) => { if (document.fullscreenElement) e.preventDefault(); });
$(TXT_ADDRESS_SELECTOR).devbridgeAutocomplete({
const autoComplete = new AutoComplete(document.querySelector(TXT_ADDRESS_SELECTOR), {
serviceUrl: 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest',
deferRequestBy: 300,
paramName: 'text',
@@ -75,16 +75,10 @@ const init = () => {
minChars: 3,
showNoSuggestionNotice: true,
noSuggestionNotice: 'No results found. Please try a different search string.',
onSelect(suggestion) { autocompleteOnSelect(suggestion, this); },
onSelect(suggestion) { autocompleteOnSelect(suggestion); },
width: 490,
});
const formSubmit = () => {
const ac = $(TXT_ADDRESS_SELECTOR).devbridgeAutocomplete();
if (ac.suggestions[0]) $(ac.suggestionsContainer.children[0]).trigger('click');
return false;
};
// attempt to parse the url parameters
const parsedParameters = parseQueryString();
@@ -132,10 +126,7 @@ const init = () => {
document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right'));
};
const autocompleteOnSelect = async (suggestion, elem) => {
// Do not auto get the same city twice.
if (elem.previousSuggestionValue === suggestion.value) return;
const autocompleteOnSelect = async (suggestion) => {
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
data: {
text: suggestion.value,

View File

@@ -0,0 +1,307 @@
/* eslint-disable default-case */
import { json } from './utils/fetch.mjs';
const KEYS = {
ESC: 27,
TAB: 9,
RETURN: 13,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
ENTER: 13,
};
const DEFAULT_OPTIONS = {
autoSelectFirst: false,
serviceUrl: null,
lookup: null,
onSelect: () => { },
onHint: null,
width: 'auto',
minChars: 3,
maxHeight: 300,
deferRequestBy: 0,
params: {},
delimiter: null,
zIndex: 9999,
type: 'GET',
noCache: false,
preserveInput: false,
containerClass: 'autocomplete-suggestions',
tabDisabled: false,
dataType: 'text',
currentRequest: null,
triggerSelectOnValidInput: true,
preventBadQueries: true,
paramName: 'query',
transformResult: (a) => a,
showNoSuggestionNotice: false,
noSuggestionNotice: 'No results',
orientation: 'bottom',
forceFixPosition: false,
};
const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
const formatResult = (suggestion, search) => {
// Do not replace anything if the current value is empty
if (!search) {
return suggestion;
}
const pattern = `(${escapeRegExChars(search)})`;
return suggestion
.replace(new RegExp(pattern, 'gi'), '<strong>$1</strong>')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/&lt;(\/?strong)&gt;/g, '<$1>');
};
class AutoComplete {
constructor(elem, options) {
this.options = { ...DEFAULT_OPTIONS, ...options };
this.elem = elem;
this.selectedItem = -1;
this.onChangeTimeout = null;
this.currentValue = '';
this.suggestions = [];
this.cachedResponses = {};
// create and add the results container
const results = document.createElement('div');
results.style.display = 'none';
results.classList.add(this.options.containerClass);
results.style.width = (typeof this.options.width === 'string') ? this.options.width : `${this.options.width}px`;
results.style.zIndex = this.options.zIndex;
results.style.maxHeight = `${this.options.maxHeight}px`;
results.style.overflowX = 'hidden';
results.addEventListener('mouseover', (e) => this.mouseOver(e));
results.addEventListener('mouseout', (e) => this.mouseOut(e));
results.addEventListener('click', (e) => this.click(e));
this.results = results;
this.elem.after(results);
// add handlers for typing text and submitting the form
this.elem.addEventListener('keyup', (e) => this.keyUp(e));
this.elem.closest('form')?.addEventListener('submit', (e) => this.directFormSubmit(e));
this.elem.addEventListener('click', () => this.deselectAll());
this.elem.addEventListener('focusout', () => this.hideSuggestions());
}
mouseOver(e) {
// suggestion line
if (e.target?.classList?.contains('suggestion')) {
e.target.classList.add('selected');
this.selectedItem = parseInt(e.target.dataset.item, 10);
}
}
mouseOut(e) {
// suggestion line
if (e.target?.classList?.contains('suggestion')) {
e.target.classList.remove('selected');
this.selectedItem = -1;
}
}
click(e) {
// suggestion line
if (e.target?.classList?.contains('suggestion')) {
// get the entire suggestion
const suggestion = this.suggestions[parseInt(e.target.dataset.item, 10)];
this.options.onSelect(suggestion);
this.elem.value = suggestion.value;
this.hideSuggestions();
}
}
hideSuggestions() {
this.results.style.display = 'none';
}
showSuggestions() {
this.results.style.removeProperty('display');
}
clearSuggestions() {
this.results.innerHTML = '';
}
keyUp(e) {
// reset the change timeout
clearTimeout(this.onChangeTimeout);
// up/down direction
switch (e.which) {
case KEYS.UP:
case KEYS.DOWN:
// move up or down the selection list
this.keySelect(e.which);
return;
case KEYS.ENTER:
// if the text entry field is active call direct form submit
// if there is a suggestion highlighted call the click function on that element
if (this.getSelected() !== undefined) {
this.click({ target: this.results.querySelector('.suggestion.selected') });
return;
}
if (document.activeElement.id === this.elem.id) {
// call the direct submit routine
this.directFormSubmit();
}
return;
}
if (this.currentValue !== this.elem.value) {
if (this.options.deferRequestBy > 0) {
// defer lookup during rapid key presses
this.onChangeTimeout = setTimeout(() => {
this.onValueChange();
}, this.options.deferRequestBy);
}
}
}
onValueChange() {
clearTimeout(this.onValueChange);
// confirm value actually changed
if (this.currentValue === this.elem.value) return;
// store new value
this.currentValue = this.elem.value;
// clear the selected index
this.selectedItem = -1;
this.results.querySelectorAll('div').forEach((elem) => elem.classList.remove('selected'));
// if less than minimum don't query api
if (this.currentValue.length < this.options.minChars) {
this.hideSuggestions();
return;
}
this.getSuggestions(this.currentValue);
}
async getSuggestions(search, skipHtml = false) {
// assemble options
const searchOptions = { ...this.options.params };
searchOptions[this.options.paramName] = search;
// build search url
const url = new URL(this.options.serviceUrl);
Object.entries(searchOptions).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
let result = this.cachedResponses[search];
if (!result) {
// make the request
const resultRaw = await json(url);
// use the provided parser
result = this.options.transformResult(resultRaw);
}
// store suggestions
this.cachedResponses[search] = result;
this.suggestions = result.suggestions;
if (skipHtml) return;
// populate the suggestion area
this.populateSuggestions();
}
populateSuggestions() {
if (this.suggestions.length === 0) {
if (this.options.showNoSuggestionNotice) {
this.noSuggestionNotice();
} else {
this.hideSuggestions();
}
return;
}
// build the list
const suggestionElems = this.suggestions.map((suggested, idx) => {
const elem = document.createElement('div');
elem.classList.add('suggestion');
elem.dataset.item = idx;
elem.innerHTML = (formatResult(suggested.value, this.currentValue));
return elem.outerHTML;
});
this.results.innerHTML = suggestionElems.join('');
this.showSuggestions();
}
noSuggestionNotice() {
this.results.innerHTML = `<div>${this.options.noSuggestionNotice}</div>`;
this.showSuggestions();
}
// the submit button has been pressed and we'll just use the first suggestion found
async directFormSubmit() {
// check for minimum length
if (this.currentValue.length < this.options.minChars) return;
await this.getSuggestions(this.elem.value, true);
const suggestion = this.suggestions?.[0];
if (suggestion) {
this.options.onSelect(suggestion);
this.elem.value = suggestion.value;
this.hideSuggestions();
}
}
// return the index of the selected item in suggestions
getSelected() {
const index = this.results.querySelector('.selected')?.dataset?.item;
if (index !== undefined) return parseInt(index, 10);
return index;
}
// move the selection highlight up or down
keySelect(key) {
// if the suggestions are hidden do nothing
if (this.results.style.display === 'none') return;
// if there are no suggestions do nothing
if (this.suggestions.length <= 0) return;
// get the currently selected index (or default to off the top of the list)
let index = this.getSelected();
// adjust the index per the key
// and include defaults in case no index is selected
switch (key) {
case KEYS.UP:
index = (index ?? 0) - 1;
break;
case KEYS.DOWN:
index = (index ?? -1) + 1;
break;
}
// wrap the index (and account for negative)
index = ((index % this.suggestions.length) + this.suggestions.length) % this.suggestions.length;
// set this index
this.deselectAll();
this.mouseOver({
target: this.results.querySelectorAll('.suggestion')[index],
});
}
deselectAll() {
// clear other selected indexes
[...this.results.querySelectorAll('.suggestion.selected')].forEach((elem) => elem.classList.remove('selected'));
this.selectedItem = 0;
}
}
export default AutoComplete;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -40,13 +40,13 @@ body {
button {
font-size: 16pt;
border: 1px solid darkgray;
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
}
border: 1px solid darkgray;
}
#btnGetGps {
@@ -106,23 +106,26 @@ body {
.autocomplete-suggestions {
background-color: #ffffff;
border: 1px solid #000000;
position: absolute;
z-index: 9999;
@media (prefers-color-scheme: dark) {
background-color: #000000;
}
.autocomplete-suggestion {
div {
/*padding: 2px 5px;*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16pt;
&.selected {
background-color: #0000ff;
color: #ffffff;
}
}
.autocomplete-selected {
background-color: #0000ff;
color: #ffffff;
}
}
#divTwc {
@@ -175,12 +178,13 @@ body {
flex-direction: row;
background-color: #000000;
color: #ffffff;
width: 100%;
@media (prefers-color-scheme: dark) {
background-color: rgb(48, 48, 48);
}
color: #ffffff;
width: 100%;
}
#divTwcBottom>div {

View File

@@ -1,15 +1,15 @@
@import 'page';
@import 'weather-display';
@import 'current-weather';
@import 'extended-forecast';
@import 'hourly';
@import 'hourly-graph';
@import 'travel';
@import 'latest-observations';
@import 'local-forecast';
@import 'progress';
@import 'radar';
@import 'regional-forecast';
@import 'almanac';
@import 'hazards';
@import 'media';
@use 'page';
@use 'weather-display';
@use 'current-weather';
@use 'extended-forecast';
@use 'hourly';
@use 'hourly-graph';
@use 'travel';
@use 'latest-observations';
@use 'local-forecast';
@use 'progress';
@use 'radar';
@use 'regional-forecast';
@use 'almanac';
@use 'hazards';
@use 'media';

View File

@@ -26,8 +26,6 @@
<script type="text/javascript" src="resources/ws.min.js?_=<%=production%>"></script>
<% } else { %>
<link rel="stylesheet" type="text/css" href="styles/main.css" />
<script type="text/javascript" src="scripts/vendor/auto/jquery.js"></script>
<script type="text/javascript" src="scripts/vendor/jquery.autocomplete.min.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/swiped-events.js"></script>
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
@@ -60,25 +58,26 @@
<body>
<div id="divQuery">
<div id="divQuery">
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" />
<div class="buttons">
<button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light"/>
<img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark"/>
<button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png"
class="light" />
<img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark" />
</button>
<button id="btnGetLatLng" type="submit">GO</button>
<button id="btnClearQuery" type="reset">Reset</button>
</div>
</div>
<div id="version" style="display:none">
<%- version %>
</div>
</div>
<div id="version" style="display:none">
<%- version %>
</div>
<div id="divTwc">
<div id="container">
<div id="loading" width="640" height="480">
<div>
<div class="title">WeatherStar 4000+</div>
<div id="divTwc">
<div id="container">
<div id="loading" width="640" height="480">
<div>
<div class="title">WeatherStar 4000+</div>
<div class="version">v<%- version %></div>
<div class="instructions">Enter your location above to continue</div>
</div>
@@ -140,17 +139,17 @@
</div>
</div>
<br />
<br />
<div class="info">
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
</div>
<div class="media"></div>
<div class='heading'>Selected displays</div>
<div id='enabledDisplays'>
<div class='heading'>Selected displays</div>
<div id='enabledDisplays'>
</div>
</div>
<div class='heading'>Settings</div>
<div id='settings'>
@@ -161,17 +160,18 @@
<a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span>
<div id="share-link-instructions">
Copy this long URL:
<input type='text' id="share-link-url"></div>
<input type='text' id="share-link-url">
</div>
</div>
</div>
<div class='heading'>Forecast Information</div>
<div id="divInfo">
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
Station Id: <span id="spanStationId"></span><br />
Radar Id: <span id="spanRadarId"></span><br />
Zone Id: <span id="spanZoneId"></span><br />
</div>
<div id="divInfo">
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
Station Id: <span id="spanStationId"></span><br />
Radar Id: <span id="spanRadarId"></span><br />
Zone Id: <span id="spanZoneId"></span><br />
</div>
</body>