├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── .vscode └── settings.json ├── BACKERS.md ├── LICENSE ├── README.md ├── _config.yml ├── api ├── _helpers │ ├── camel-case-keys.ts │ └── to-camel-case.ts ├── location │ ├── _helpers │ │ └── map-location.ts │ ├── coordinates.ts │ └── search.ts ├── package.json ├── tsconfig.json └── weather │ └── forecast.ts ├── client ├── .browserslistrc ├── babel.config.js ├── build │ ├── _base │ │ ├── config.js │ │ └── workbox.js │ ├── development.js │ ├── insights.js │ └── production.js ├── package.json ├── postcss.config.js ├── src │ ├── app.vue │ ├── assets │ │ └── images │ │ │ ├── figures │ │ │ ├── fog.svg │ │ │ ├── full-moon.svg │ │ │ ├── index.ts │ │ │ ├── light-rain.svg │ │ │ ├── light-snow.svg │ │ │ ├── moderate-rain.svg │ │ │ ├── night-wind.svg │ │ │ ├── partly-cloudy-day.svg │ │ │ ├── partly-cloudy-night.svg │ │ │ ├── rain-cloud.svg │ │ │ ├── rain.svg │ │ │ ├── rainy-night.svg │ │ │ ├── snow.svg │ │ │ ├── snowy-sunny-day.svg │ │ │ ├── storm.svg │ │ │ ├── stormy-night.svg │ │ │ ├── stormy-weather.svg │ │ │ ├── sun.svg │ │ │ ├── weather.svg │ │ │ ├── wind.svg │ │ │ └── windy-weather.svg │ │ │ └── logo │ │ │ ├── logo-192.png │ │ │ ├── logo-192.svg │ │ │ ├── logo-512.png │ │ │ └── logo-512.svg │ ├── components │ │ ├── charts │ │ │ ├── _base │ │ │ │ └── chart.ts │ │ │ ├── line.vue │ │ │ └── trends.vue │ │ ├── drawers │ │ │ └── maps.vue │ │ ├── forecast │ │ │ ├── daily-forecast.vue │ │ │ ├── hourly-forecast.vue │ │ │ ├── summary.vue │ │ │ ├── tides.vue │ │ │ ├── today.vue │ │ │ └── uv-index.vue │ │ ├── layouts │ │ │ ├── settings.vue │ │ │ └── weather.vue │ │ ├── modals │ │ │ └── location.vue │ │ ├── settings │ │ │ └── settings-item.vue │ │ └── weather │ │ │ ├── actions.vue │ │ │ └── observation.vue │ ├── constants │ │ ├── core │ │ │ ├── data.ts │ │ │ ├── drawers.ts │ │ │ ├── events.ts │ │ │ ├── global.ts │ │ │ ├── migrations.ts │ │ │ ├── modals.ts │ │ │ ├── routes.ts │ │ │ ├── settings.ts │ │ │ └── storage-keys.ts │ │ ├── forecast │ │ │ ├── directions.ts │ │ │ ├── figure.ts │ │ │ ├── formats.ts │ │ │ ├── formatters.ts │ │ │ ├── icon.ts │ │ │ ├── sections.ts │ │ │ ├── theme.ts │ │ │ ├── tides-chart-options.ts │ │ │ ├── trends.ts │ │ │ ├── unit-of-measure.ts │ │ │ ├── units.ts │ │ │ └── uv-index.ts │ │ └── maps │ │ │ └── maps.ts │ ├── controllers │ │ └── application.ts │ ├── enums │ │ ├── core │ │ │ └── status.ts │ │ ├── forecast │ │ │ ├── location.ts │ │ │ ├── observation.ts │ │ │ ├── phase.ts │ │ │ ├── section.ts │ │ │ ├── trend.ts │ │ │ ├── unit-of-measure.ts │ │ │ └── units.ts │ │ └── maps │ │ │ └── map.ts │ ├── helpers │ │ ├── get-direction.ts │ │ ├── get-figure.ts │ │ ├── get-icon.ts │ │ ├── get-phase.ts │ │ └── set-theme-meta.ts │ ├── index.ejs │ ├── index.ts │ ├── routes │ │ ├── error.vue │ │ ├── error │ │ │ ├── index.ts │ │ │ ├── index.vue │ │ │ └── not-found.vue │ │ ├── forecast.vue │ │ ├── forecast │ │ │ ├── index.ts │ │ │ └── index.vue │ │ ├── index.ts │ │ ├── maps.vue │ │ ├── maps │ │ │ ├── index.ts │ │ │ └── index.vue │ │ ├── settings.vue │ │ └── settings │ │ │ ├── forecast │ │ │ ├── index.ts │ │ │ ├── locations.vue │ │ │ └── sections.vue │ │ │ ├── general │ │ │ ├── about.vue │ │ │ ├── index.ts │ │ │ └── theme.vue │ │ │ ├── index.ts │ │ │ ├── index.vue │ │ │ └── maps │ │ │ ├── display.vue │ │ │ └── index.ts │ ├── services │ │ ├── location.ts │ │ └── weather.ts │ ├── startup │ │ ├── application.ts │ │ ├── components.ts │ │ ├── index.ts │ │ ├── logging.ts │ │ ├── router.ts │ │ ├── state.ts │ │ ├── vendor.ts │ │ └── worker.ts │ ├── static │ │ ├── .well-known │ │ │ └── somthing.txt │ │ ├── icons.svg │ │ ├── robots.txt │ │ └── sitemap.xml │ ├── store │ │ ├── actions │ │ │ ├── add-location.ts │ │ │ ├── load-forecast.ts │ │ │ ├── load-location.ts │ │ │ ├── move-section.ts │ │ │ ├── remove-location.ts │ │ │ ├── reset-settings.ts │ │ │ ├── search-locations.ts │ │ │ ├── set-current-location.ts │ │ │ ├── set-location.ts │ │ │ ├── set-section-visibility.ts │ │ │ ├── update-settings.ts │ │ │ └── update.ts │ │ ├── getters │ │ │ ├── forecast.ts │ │ │ ├── format.ts │ │ │ ├── phase.ts │ │ │ ├── theme.ts │ │ │ └── unit-of-measure.ts │ │ ├── helpers │ │ │ ├── location.ts │ │ │ └── storage.ts │ │ ├── index.ts │ │ ├── mutations │ │ │ ├── set-last-updated.ts │ │ │ ├── set-settings.ts │ │ │ └── set-status.ts │ │ ├── state │ │ │ └── index.ts │ │ └── store.ts │ ├── themes │ │ ├── core │ │ │ ├── dark │ │ │ │ ├── _dark.scss │ │ │ │ ├── index.scss │ │ │ │ └── index.ts │ │ │ ├── default │ │ │ │ ├── index.scss │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── light │ │ │ │ ├── _light.scss │ │ │ │ ├── index.scss │ │ │ │ └── index.ts │ │ ├── index.ts │ │ └── weather │ │ │ ├── clear │ │ │ ├── index.scss │ │ │ └── index.ts │ │ │ ├── default │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── partly-cloudy │ │ │ ├── index.scss │ │ │ └── index.ts │ │ │ └── rainy │ │ │ ├── index.scss │ │ │ └── index.ts │ ├── types │ │ ├── location.ts │ │ ├── state.ts │ │ ├── storage.ts │ │ ├── themes.ts │ │ └── weather.ts │ └── vue-shim.d.ts ├── tsconfig.json └── webpack.config.babel.js ├── package.json ├── packages ├── charts │ ├── package.json │ └── src │ │ ├── charts │ │ ├── _base │ │ │ └── chart.ts │ │ └── line │ │ │ ├── constants │ │ │ └── curve.ts │ │ │ ├── enums │ │ │ ├── line-type.ts │ │ │ └── marker-type.ts │ │ │ ├── index.ts │ │ │ └── types │ │ │ └── index.ts │ │ ├── d3 │ │ └── index.ts │ │ ├── enums │ │ └── scale.ts │ │ ├── index.ts │ │ └── scales │ │ ├── index.ts │ │ ├── linear.ts │ │ ├── point.ts │ │ └── time.ts ├── components │ ├── package.json │ └── src │ │ ├── components │ │ ├── accordion │ │ │ ├── accordion-pane.vue │ │ │ ├── accordion.vue │ │ │ └── constants │ │ │ │ └── events.ts │ │ ├── block │ │ │ └── block.vue │ │ ├── container │ │ │ └── container.vue │ │ ├── core │ │ │ ├── confirm-modal.vue │ │ │ └── index.vue │ │ ├── drawer │ │ │ └── drawer.vue │ │ ├── icon-button │ │ │ └── icon-button.vue │ │ ├── icon-label │ │ │ └── icon-label.vue │ │ ├── icon │ │ │ └── icon.vue │ │ ├── index.ts │ │ ├── layout │ │ │ └── layout.vue │ │ ├── loader │ │ │ └── loader.vue │ │ ├── mapbox │ │ │ ├── compositions │ │ │ │ └── layer.ts │ │ │ ├── mapbox-legend.vue │ │ │ ├── mapbox-map.vue │ │ │ ├── mapbox-raster-layer.vue │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── modal │ │ │ └── modal.vue │ │ ├── search-box │ │ │ └── search-box.vue │ │ └── transitions │ │ │ └── box-resize.vue │ │ ├── compositions │ │ ├── layer.ts │ │ ├── subscriber.ts │ │ └── timer.ts │ │ ├── constants │ │ └── modals.ts │ │ ├── controllers │ │ └── components.ts │ │ ├── directives │ │ ├── focus.ts │ │ ├── index.ts │ │ ├── meta.ts │ │ ├── tooltip.ts │ │ └── visible.ts │ │ ├── event-emitter │ │ └── index.ts │ │ ├── helpers │ │ └── get-listeners.ts │ │ ├── index.ts │ │ └── types │ │ └── index.ts ├── event-emitter │ ├── package.json │ └── src │ │ └── index.ts ├── router │ ├── package.json │ └── src │ │ └── index.ts ├── state │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── types │ │ │ └── index.ts │ └── tsconfig.json ├── style │ ├── package.json │ └── src │ │ ├── _base.scss │ │ ├── _buttons.scss │ │ ├── _dots.scss │ │ ├── _grid.scss │ │ ├── _inputs.scss │ │ ├── _menus.scss │ │ ├── _mixins.scss │ │ ├── _spacing.scss │ │ ├── _tables.scss │ │ ├── _tooltips.scss │ │ ├── _typography.scss │ │ ├── _variables.scss │ │ └── index.scss ├── task-queue │ ├── package.json │ └── src │ │ └── index.ts └── utilities │ ├── package.json │ ├── src │ ├── array │ │ ├── join-by.ts │ │ ├── order-by.ts │ │ ├── swap-by.ts │ │ ├── union-with.ts │ │ └── unique-by.ts │ ├── date │ │ ├── format-distance-to-now.ts │ │ ├── format-distance.ts │ │ ├── format.ts │ │ ├── from-unix.ts │ │ ├── is-today.ts │ │ ├── to-unix.ts │ │ └── utc-to-zoned.ts │ ├── dom │ │ └── set-meta.ts │ ├── env │ │ ├── _base │ │ │ └── is-env.ts │ │ ├── is-development.ts │ │ └── is-production.ts │ ├── function │ │ ├── debounce.ts │ │ ├── identity.ts │ │ └── noop.ts │ ├── index.ts │ ├── number │ │ ├── clamp.ts │ │ ├── max-by.ts │ │ ├── min-by.ts │ │ ├── percentage.ts │ │ └── round.ts │ ├── object │ │ ├── clone-lazy.ts │ │ ├── merge-with.ts │ │ ├── merge.ts │ │ └── transform.ts │ ├── scale │ │ ├── _base │ │ │ └── scale.ts │ │ ├── continuous.ts │ │ └── discrete.ts │ ├── string │ │ ├── capitalize.ts │ │ └── unique-id.ts │ ├── type │ │ ├── is-array.ts │ │ ├── is-date.ts │ │ ├── is-function.ts │ │ ├── is-nil.ts │ │ ├── is-number.ts │ │ ├── is-plain-object.ts │ │ └── is-string.ts │ └── value │ │ └── get-accessor.ts │ └── tsconfig.json ├── tsconfig.json ├── vercel.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ocula 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | day: wednesday 8 | time: "12:00" 9 | timezone: Australia/Brisbane 10 | open-pull-requests-limit: 10 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | jspm_packages/ 3 | typings/ 4 | public/ 5 | 6 | .cache 7 | .env 8 | .env.build 9 | 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | .now 16 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/node_modules": true, 9 | "**/*.log": true 10 | }, 11 | "npm.packageManager": "yarn", 12 | "vetur.experimental.templateInterpolationService": true 13 | } -------------------------------------------------------------------------------- /BACKERS.md: -------------------------------------------------------------------------------- 1 | # Backers 2 | 3 | Kerry Tarrant 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrew Courtice 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /api/_helpers/camel-case-keys.ts: -------------------------------------------------------------------------------- 1 | import toCamelCase from './to-camel-case'; 2 | 3 | export default function camelCaseKeys(data: Record): Record { 4 | const output = {}; 5 | 6 | for (const key in data) { 7 | let value = data[key]; 8 | const camelKey = toCamelCase(key); 9 | 10 | switch (true) { 11 | case Array.isArray(value): 12 | value = value.map(camelCaseKeys); 13 | break; 14 | case typeof value === 'object': 15 | value = camelCaseKeys(value); 16 | break; 17 | } 18 | 19 | output[camelKey] = value; 20 | } 21 | 22 | return output; 23 | } -------------------------------------------------------------------------------- /api/_helpers/to-camel-case.ts: -------------------------------------------------------------------------------- 1 | export default function(value: string): string { 2 | return value.toLowerCase().replace(/([-_]\w)/g, group => group[1].toUpperCase()); 3 | } -------------------------------------------------------------------------------- /api/location/_helpers/map-location.ts: -------------------------------------------------------------------------------- 1 | export default function(feature) { 2 | const { 3 | id, 4 | text, 5 | place_name, 6 | center 7 | } = feature; 8 | 9 | return { 10 | id, 11 | shortName: text, 12 | longName: place_name, 13 | latitude: center[1], 14 | longitude: center[0] 15 | }; 16 | } -------------------------------------------------------------------------------- /api/location/coordinates.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import mapLocation from './_helpers/map-location'; 3 | 4 | import { 5 | NowRequest, 6 | NowResponse 7 | } from '@vercel/node'; 8 | 9 | export default async function (request: NowRequest, response: NowResponse) { 10 | const { 11 | latitude, 12 | longitude 13 | } = request.query; 14 | 15 | const apiKey = process.env.MAPBOX_API_KEY; 16 | const apiResponse = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${longitude},${latitude}.json?access_token=${apiKey}&types=locality,place&limit=1`); 17 | 18 | const { 19 | features 20 | } = await apiResponse.json(); 21 | 22 | if (!features || features.length < 1) { 23 | response.statusCode = 500; 24 | } 25 | 26 | const output = mapLocation(features[0]); 27 | 28 | return response.json(output); 29 | } -------------------------------------------------------------------------------- /api/location/search.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import mapLocation from './_helpers/map-location'; 3 | 4 | import { 5 | NowRequest, 6 | NowResponse 7 | } from '@vercel/node'; 8 | 9 | export default async function (request: NowRequest, response: NowResponse) { 10 | const { 11 | query 12 | } = request.query; 13 | 14 | const apiKey = process.env.MAPBOX_API_KEY; 15 | const apiResponse = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${query}.json?access_token=${apiKey}&types=locality,place&limit=5&autocomplete=true`); 16 | 17 | const { 18 | features 19 | } = await apiResponse.json(); 20 | 21 | if (!features || features.length < 1) { 22 | response.statusCode = 500; 23 | } 24 | 25 | const output = features.map(mapLocation); 26 | 27 | return response.json(output); 28 | } -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/api", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/andrewcourtice/ocula.git", 5 | "author": "Andrew Courtice", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@vercel/node": "^1.8.5", 9 | "lodash": "^4.17.20", 10 | "node-fetch": "^2.6.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /api/weather/forecast.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import camelCaseKeys from '../_helpers/camel-case-keys'; 4 | 5 | import { 6 | NowRequest, 7 | NowResponse 8 | } from '@vercel/node'; 9 | 10 | export default async function (request: NowRequest, response: NowResponse) { 11 | let { 12 | latitude, 13 | longitude, 14 | units 15 | } = request.query; 16 | 17 | units = units || 'metric'; 18 | 19 | const owmApiKey = process.env.OWM_API_KEY; 20 | const worldtidesApiKey = process.env.WORLDTIDES_API_KEY; 21 | 22 | const responses = await Promise.all([ 23 | fetch(`https://api.openweathermap.org/data/2.5/onecall?appid=${owmApiKey}&lat=${latitude}&lon=${longitude}&units=${units}&exclude=minutely`), 24 | fetch(`https://www.worldtides.info/api/v2?heights&extremes&date=today&days=1&step=3600&lat=${latitude}&lon=${longitude}&key=${worldtidesApiKey}`), 25 | fetch('https://tilecache.rainviewer.com/api/maps.json') 26 | ]); 27 | 28 | let [ 29 | forecast, 30 | tides, 31 | timestamps 32 | ] = await Promise.all(responses.map(response => response.json())); 33 | 34 | forecast = camelCaseKeys(forecast); 35 | 36 | return response.json({ 37 | ...forecast, 38 | tides, 39 | radar: { 40 | timestamps 41 | } 42 | }); 43 | } -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | chrome >= 58 2 | firefox >= 54 3 | edge >= 18 4 | safari >= 11 5 | ios_saf >= 11 6 | opera >= 55 -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/env', { 4 | useBuiltIns: 'entry', 5 | corejs: 3 6 | }], 7 | '@babel/typescript' 8 | ], 9 | plugins: [ 10 | ['const-enum', { 11 | transform: 'constObject' 12 | }], 13 | '@babel/transform-typescript' 14 | ] 15 | }; -------------------------------------------------------------------------------- /client/build/_base/workbox.js: -------------------------------------------------------------------------------- 1 | export default { 2 | swDest: 'service-worker.js', 3 | clientsClaim: true, 4 | //skipWaiting: true, 5 | navigateFallback: '/index.html', 6 | runtimeCaching: [ 7 | { 8 | urlPattern: /^https:\/\/fonts\.googleapis\.com/, 9 | handler: 'StaleWhileRevalidate', 10 | options: { 11 | cacheName: 'ocula-fonts' 12 | } 13 | }, 14 | { 15 | urlPattern: /.*fontawesome\.com/, 16 | handler: 'StaleWhileRevalidate', 17 | options: { 18 | cacheName: 'ocula-fonts' 19 | } 20 | } 21 | ] 22 | }; -------------------------------------------------------------------------------- /client/build/development.js: -------------------------------------------------------------------------------- 1 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 2 | 3 | import merge from 'webpack-merge'; 4 | import base from './_base/config'; 5 | 6 | const CSS_LOADERS = [ 7 | 'vue-style-loader', 8 | 'css-loader', 9 | { 10 | loader: 'postcss-loader', 11 | options: { 12 | config: { 13 | path: 'client/' 14 | } 15 | } 16 | } 17 | ]; 18 | 19 | export default merge(base, { 20 | 21 | mode: 'development', 22 | 23 | devServer: { 24 | port: 3000, 25 | hot: true, 26 | noInfo: true, 27 | historyApiFallback: true, 28 | clientLogLevel: 'warning' 29 | }, 30 | 31 | output: { 32 | filename: '[name]-[hash].js', 33 | chunkFilename: '[name]-[hash].js' 34 | }, 35 | 36 | devtool: 'cheap-module-eval-source-map', 37 | 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.css$/, 42 | use: CSS_LOADERS 43 | }, 44 | { 45 | test: /\.scss$/, 46 | use: [].concat(CSS_LOADERS, 'sass-loader'), 47 | exclude: { 48 | test: /node_modules/, 49 | not: [ 50 | /@ocula/ 51 | ] 52 | } 53 | }, 54 | { 55 | test: /\.sass$/, 56 | use: [].concat(CSS_LOADERS, 'sass-loader?indentedSyntax'), 57 | exclude: { 58 | test: /node_modules/, 59 | not: [ 60 | /@ocula/ 61 | ] 62 | } 63 | } 64 | ] 65 | }, 66 | 67 | plugins: [ 68 | 69 | new MiniCssExtractPlugin({ 70 | filename: '[name]-[hash].css', 71 | chunkFilename: '[name]-[hash].css' 72 | }) 73 | 74 | ] 75 | }); -------------------------------------------------------------------------------- /client/build/insights.js: -------------------------------------------------------------------------------- 1 | import merge from 'webpack-merge'; 2 | import production from './production'; 3 | 4 | import { 5 | BundleAnalyzerPlugin 6 | } from 'webpack-bundle-analyzer'; 7 | 8 | export default merge(production, { 9 | 10 | plugins: [ 11 | 12 | new BundleAnalyzerPlugin({ 13 | analyzerMode: 'static', 14 | reportFilename: 'ocula-bundle-report.html' 15 | }) 16 | 17 | ] 18 | }); 19 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer') 4 | ] 5 | }; -------------------------------------------------------------------------------- /client/src/assets/images/figures/fog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/full-moon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/index.ts: -------------------------------------------------------------------------------- 1 | import fog from './fog.svg'; 2 | import fullMoon from './full-moon.svg'; 3 | import lightRain from './light-rain.svg'; 4 | import lightSnow from './light-snow.svg'; 5 | import moderateRain from './moderate-rain.svg'; 6 | import nightWind from './night-wind.svg'; 7 | import partlyCloudyDay from './partly-cloudy-day.svg'; 8 | import partlyCloudyNight from './partly-cloudy-night.svg'; 9 | import rain from './rain.svg'; 10 | import rainCloud from './rain-cloud.svg'; 11 | import rainyNight from './rainy-night.svg'; 12 | import snow from './snow.svg'; 13 | import snowySunnyDay from './snowy-sunny-day.svg'; 14 | import storm from './storm.svg'; 15 | import stormyNight from './stormy-night.svg'; 16 | import stormyWeather from './stormy-weather.svg'; 17 | import sun from './sun.svg'; 18 | import weather from './weather.svg'; 19 | import wind from './wind.svg'; 20 | import windyWeather from './windy-weather.svg'; 21 | 22 | export default { 23 | fog, 24 | fullMoon, 25 | lightRain, 26 | lightSnow, 27 | moderateRain, 28 | nightWind, 29 | partlyCloudyDay, 30 | partlyCloudyNight, 31 | rain, 32 | rainCloud, 33 | rainyNight, 34 | snow, 35 | snowySunnyDay, 36 | storm, 37 | stormyNight, 38 | stormyWeather, 39 | sun, 40 | weather, 41 | wind, 42 | windyWeather 43 | }; -------------------------------------------------------------------------------- /client/src/assets/images/figures/light-rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/light-snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/partly-cloudy-day.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/partly-cloudy-night.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/rain-cloud.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/rain.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/rainy-night.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/snowy-sunny-day.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/storm.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/stormy-night.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/stormy-weather.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/sun.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/weather.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/wind.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/figures/windy-weather.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/logo/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewcourtice/ocula/f49a46a1b83bf2d511b51241ded71819e1141d39/client/src/assets/images/logo/logo-192.png -------------------------------------------------------------------------------- /client/src/assets/images/logo/logo-192.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/logo/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewcourtice/ocula/f49a46a1b83bf2d511b51241ded71819e1141d39/client/src/assets/images/logo/logo-512.png -------------------------------------------------------------------------------- /client/src/assets/images/logo/logo-512.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /client/src/components/charts/_base/chart.ts: -------------------------------------------------------------------------------- 1 | import EVENTS from '../../../constants/core/events'; 2 | 3 | import { 4 | defineComponent, 5 | ref, 6 | watch, 7 | onMounted, 8 | onBeforeUnmount, 9 | WatchStopHandle, 10 | } from 'vue'; 11 | 12 | import { 13 | useSubscriber 14 | } from '@ocula/components'; 15 | 16 | export default function chart(Chart) { 17 | return defineComponent({ 18 | 19 | props: { 20 | 21 | data: { 22 | type: Array, 23 | default: () => [] 24 | }, 25 | 26 | options: { 27 | type: Object 28 | }, 29 | 30 | autoRender: { 31 | type: Boolean, 32 | default: true 33 | }, 34 | 35 | autoUpdate: { 36 | type: Boolean, 37 | default: true 38 | } 39 | 40 | }, 41 | 42 | setup(props) { 43 | let chart; 44 | let watchHandle: WatchStopHandle; 45 | 46 | const element = ref(null); 47 | 48 | async function render() { 49 | if (!chart) { 50 | return; 51 | } 52 | 53 | return chart.render(props.data, props.options); 54 | } 55 | 56 | useSubscriber(EVENTS.application.resized, render); 57 | 58 | onMounted(() => { 59 | chart = new Chart(element.value); 60 | 61 | if (props.autoRender) { 62 | render(); 63 | } 64 | 65 | if (props.autoUpdate) { 66 | watchHandle = watch([ 67 | () => props.data, 68 | () => props.options 69 | ], render); 70 | } 71 | }); 72 | 73 | onBeforeUnmount(() => watchHandle && watchHandle()); 74 | 75 | return { 76 | element 77 | }; 78 | } 79 | 80 | }); 81 | } -------------------------------------------------------------------------------- /client/src/components/charts/line.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | -------------------------------------------------------------------------------- /client/src/components/drawers/maps.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 52 | 53 | 64 | -------------------------------------------------------------------------------- /client/src/components/forecast/tides.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 64 | 65 | -------------------------------------------------------------------------------- /client/src/components/layouts/settings.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | 38 | -------------------------------------------------------------------------------- /client/src/components/settings/settings-item.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | 35 | -------------------------------------------------------------------------------- /client/src/components/weather/actions.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | 50 | 84 | -------------------------------------------------------------------------------- /client/src/components/weather/observation.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 36 | 37 | -------------------------------------------------------------------------------- /client/src/constants/core/data.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | lastUpdated: null, 3 | location: null, 4 | forecast: null 5 | }; -------------------------------------------------------------------------------- /client/src/constants/core/drawers.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | maps: 'drawer:maps' 3 | } as const; -------------------------------------------------------------------------------- /client/src/constants/core/events.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | application: { 3 | visible: 'application:visible', 4 | resized: 'application:resized' 5 | }, 6 | storage: { 7 | dataSaved: 'storage:data-saved', 8 | settingsSaved: 'storage:settings-saved' 9 | }, 10 | location: { 11 | set: 'location:set' 12 | } 13 | }; -------------------------------------------------------------------------------- /client/src/constants/core/global.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | updateThreshold: 1800000 3 | }; -------------------------------------------------------------------------------- /client/src/constants/core/migrations.ts: -------------------------------------------------------------------------------- 1 | import SETTINGS from '../core/settings'; 2 | 3 | import { 4 | arrayUnionWith, 5 | objectTransform 6 | } from '@ocula/utilities'; 7 | 8 | import type { 9 | ISettings 10 | } from '../../types/storage'; 11 | 12 | type Migration = (settings: ISettings) => ISettings; 13 | 14 | /* 15 | Dictionary to migrate settings structures to new versions. 16 | For example, removing a section using the object transformer: 17 | { 18 | 3: settings => objectTransform(settings, { 19 | forecast: { 20 | sections: sections => sections.filter(({ type }) => type !== 'today') 21 | } 22 | }) 23 | } 24 | */ 25 | 26 | export default { 27 | '1': settings => objectTransform(settings, { 28 | forecast: { 29 | sections: sections => arrayUnionWith(sections, SETTINGS.forecast.sections, (a, b) => a.type === b.type) 30 | } 31 | }) 32 | } as Record -------------------------------------------------------------------------------- /client/src/constants/core/modals.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | locations: 'modal:locations' 3 | } as const; -------------------------------------------------------------------------------- /client/src/constants/core/routes.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | forecast: { 3 | index: 'forecast:index', 4 | }, 5 | maps: { 6 | index: 'maps:index' 7 | }, 8 | settings: { 9 | index: 'settings:index', 10 | forecast: { 11 | locations: 'settings:forecast:locations', 12 | sections: 'settings:forecast:sections', 13 | }, 14 | maps: { 15 | display: 'settings:maps:display' 16 | }, 17 | general: { 18 | theme: 'settings:general:theme', 19 | about: 'settings:general:about' 20 | } 21 | }, 22 | error: { 23 | index: 'error:index', 24 | notFound: 'error:not:found' 25 | } 26 | } as const; -------------------------------------------------------------------------------- /client/src/constants/core/settings.ts: -------------------------------------------------------------------------------- 1 | import UNITS from '../../enums/forecast/units'; 2 | import MAP from '../../enums/maps/map'; 3 | import FORECAST_SECTION from '../../enums/forecast/section'; 4 | 5 | import type { 6 | ISettings 7 | } from '../../types/storage'; 8 | 9 | export default { 10 | version: 1.1, 11 | units: UNITS.metric, 12 | theme: 'default', 13 | location: null, 14 | locations: [], 15 | forecast: { 16 | sections: [ 17 | { 18 | type: FORECAST_SECTION.today, 19 | visible: true 20 | }, 21 | { 22 | type: FORECAST_SECTION.dailyForecast, 23 | visible: true 24 | }, 25 | { 26 | type: FORECAST_SECTION.hourlyForecast, 27 | visible: true 28 | }, 29 | { 30 | type: FORECAST_SECTION.uvIndex, 31 | visible: true 32 | }, 33 | { 34 | type: FORECAST_SECTION.tides, 35 | visible: true 36 | } 37 | ] 38 | }, 39 | maps: { 40 | default: MAP.radar, 41 | zoom: 6, 42 | pitch: 0, 43 | framerate: 500 44 | } 45 | } as ISettings; -------------------------------------------------------------------------------- /client/src/constants/core/storage-keys.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | data: 'ocula:data', 3 | settings: 'ocula:settings' 4 | }; -------------------------------------------------------------------------------- /client/src/constants/forecast/directions.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'N', 3 | 'NNE', 4 | 'NE', 5 | 'ENE', 6 | 'E', 7 | 'ESE', 8 | 'SE', 9 | 'SSE', 10 | 'S', 11 | 'SSW', 12 | 'SW', 13 | 'WSW', 14 | 'W', 15 | 'WNW', 16 | 'NW', 17 | 'NNW' 18 | ]; -------------------------------------------------------------------------------- /client/src/constants/forecast/figure.ts: -------------------------------------------------------------------------------- 1 | import figures from '../../assets/images/figures'; 2 | import PHASE from '../../enums/forecast/phase'; 3 | 4 | interface IFigure extends Record {}; 5 | 6 | export default { 7 | [PHASE.day]: { 8 | 200: figures.storm, 9 | 300: figures.lightRain, 10 | 500: figures.rain, 11 | 600: figures.snow, 12 | 700: figures.partlyCloudyDay, 13 | 800: figures.sun, 14 | 801: figures.partlyCloudyDay, 15 | 802: figures.partlyCloudyDay, 16 | 803: figures.partlyCloudyDay, 17 | 804: figures.partlyCloudyDay, 18 | }, 19 | [PHASE.night]: { 20 | 200: figures.stormyNight, 21 | 300: figures.rainyNight, 22 | 500: figures.rainyNight, 23 | 600: figures.snow, 24 | 700: figures.partlyCloudyNight, 25 | 800: figures.fullMoon, 26 | 801: figures.partlyCloudyNight, 27 | 802: figures.partlyCloudyNight, 28 | 803: figures.partlyCloudyNight, 29 | 804: figures.partlyCloudyNight, 30 | } 31 | } as Record; -------------------------------------------------------------------------------- /client/src/constants/forecast/formatters.ts: -------------------------------------------------------------------------------- 1 | import UNIT_OF_MEASURE from '../../enums/forecast/unit-of-measure'; 2 | 3 | import getIcon from '../../helpers/get-icon'; 4 | import getDirection from '../../helpers/get-direction'; 5 | 6 | import { 7 | dateFromUnix, 8 | functionIdentity, 9 | stringCapitalize 10 | } from '@ocula/utilities'; 11 | 12 | function baseFormatter(raw: T, formatted: any) { 13 | return { 14 | raw, 15 | formatted, 16 | 17 | toString() { 18 | return formatted; 19 | } 20 | }; 21 | }; 22 | 23 | function toSuffix(suffix: string, transformer: Function = functionIdentity) { 24 | return value => baseFormatter(value, `${transformer(value)}${suffix}`); 25 | } 26 | 27 | export function defaultFormatter(value) { 28 | return baseFormatter(value, value); 29 | } 30 | 31 | export default { 32 | distance: { 33 | millimeters: toSuffix(UNIT_OF_MEASURE.millimeters), 34 | centimetres: toSuffix(UNIT_OF_MEASURE.centimetres), 35 | metres: toSuffix(UNIT_OF_MEASURE.metres), 36 | kilometres: toSuffix(UNIT_OF_MEASURE.kilometres), 37 | miles: toSuffix(UNIT_OF_MEASURE.miles), 38 | inches: toSuffix(UNIT_OF_MEASURE.inches) 39 | }, 40 | speed: { 41 | millimetresPerHour: toSuffix(UNIT_OF_MEASURE.millimetresPerHour), 42 | kilometresPerHour: toSuffix(UNIT_OF_MEASURE.kilometresPerHour), 43 | metresPerSecond: toSuffix(UNIT_OF_MEASURE.metresPerSecond), 44 | inchesPerHour: toSuffix(UNIT_OF_MEASURE.inchesPerHour), 45 | milesPerHour: toSuffix(UNIT_OF_MEASURE.milesPerHour) 46 | }, 47 | temperature: { 48 | celcius: toSuffix(UNIT_OF_MEASURE.celcius, Math.round), 49 | fahrenheit: toSuffix(UNIT_OF_MEASURE.fahrenheit, Math.round), 50 | }, 51 | pressure: { 52 | hectopascals: toSuffix(UNIT_OF_MEASURE.hectopascals), 53 | millibars: toSuffix(UNIT_OF_MEASURE.millibars) 54 | }, 55 | direction: { 56 | bearing: value => baseFormatter(value, getDirection(value)) 57 | }, 58 | general: { 59 | description: value => baseFormatter(value, stringCapitalize(value)), 60 | datetime: value => baseFormatter(value, dateFromUnix(value)), 61 | icon: value => baseFormatter(value, getIcon(value)), 62 | percentage: toSuffix(UNIT_OF_MEASURE.percentage, value => Math.round(value)), 63 | fractional: toSuffix(UNIT_OF_MEASURE.percentage, value => Math.round(value * 100)) 64 | } 65 | }; -------------------------------------------------------------------------------- /client/src/constants/forecast/icon.ts: -------------------------------------------------------------------------------- 1 | import PHASE from '../../enums/forecast/phase'; 2 | 3 | export default { 4 | [PHASE.day]: { 5 | 200: 'thunderstorms-line', 6 | 300: 'drizzle-line', 7 | 500: 'showers-line', 8 | 502: 'heavy-showers-line', 9 | 503: 'heavy-showers-line', 10 | 504: 'heavy-showers-line', 11 | 511: 'snowy-line', 12 | 600: 'snowy-line', 13 | 700: 'cloudy-line', 14 | 701: 'mist-line', 15 | 711: 'haze-line', 16 | 721: 'haze-line', 17 | 731: 'haze-line', 18 | 741: 'sun-foggy-line', 19 | 751: 'haze-line', 20 | 761: 'haze-line', 21 | 781: 'tornado-line', 22 | 800: 'sun-line', 23 | 801: 'cloudy-line', 24 | 802: 'cloudy-line', 25 | 803: 'cloudy-line', 26 | 804: 'cloudy-line' 27 | }, 28 | [PHASE.night]: { 29 | 200: 'thunderstorms-line', 30 | 300: 'drizzle-line', 31 | 500: 'showers-line', 32 | 502: 'heavy-showers-line', 33 | 503: 'heavy-showers-line', 34 | 504: 'heavy-showers-line', 35 | 511: 'snowy-line', 36 | 600: 'snowy-line', 37 | 700: 'moon-cloudy-line', 38 | 701: 'mist-line', 39 | 711: 'haze-line', 40 | 721: 'haze-line', 41 | 731: 'haze-line', 42 | 741: 'moon-foggy-line', 43 | 751: 'haze-line', 44 | 761: 'haze-line', 45 | 781: 'tornado-line', 46 | 800: 'moon-clear-line', 47 | 801: 'moon-cloudy-line', 48 | 802: 'moon-cloudy-line', 49 | 803: 'moon-cloudy-line', 50 | 804: 'moon-cloudy-line', 51 | } 52 | }; -------------------------------------------------------------------------------- /client/src/constants/forecast/sections.ts: -------------------------------------------------------------------------------- 1 | import FORECAST_SECTION from '../../enums/forecast/section'; 2 | 3 | import DailyForecast from '../../components/forecast/daily-forecast.vue'; 4 | import HourlyForecast from '../../components/forecast/hourly-forecast.vue'; 5 | import Today from '../../components/forecast/today.vue'; 6 | import UvIndex from '../../components/forecast/uv-index.vue'; 7 | import Tides from '../../components/forecast/tides.vue'; 8 | 9 | import type { 10 | Formatted, 11 | IMappedForecast 12 | } from '../../types/state'; 13 | 14 | interface IForecastSection { 15 | label: string; 16 | component: typeof DailyForecast, 17 | condition?(forecast: Formatted): boolean; 18 | } 19 | 20 | export default { 21 | [FORECAST_SECTION.dailyForecast]: { 22 | label: 'Daily Forecast', 23 | component: DailyForecast 24 | }, 25 | [FORECAST_SECTION.hourlyForecast]: { 26 | label: 'Hourly Forecast', 27 | component: HourlyForecast 28 | }, 29 | [FORECAST_SECTION.today]: { 30 | label: 'Today', 31 | component: Today 32 | }, 33 | [FORECAST_SECTION.uvIndex]: { 34 | label: 'UV Index', 35 | component: UvIndex 36 | }, 37 | [FORECAST_SECTION.tides]: { 38 | label: 'Tides', 39 | component: Tides, 40 | condition: forecast => forecast.tides && forecast.tides.status.raw === 200 41 | } 42 | } as Record; -------------------------------------------------------------------------------- /client/src/constants/forecast/theme.ts: -------------------------------------------------------------------------------- 1 | import { 2 | weather 3 | } from '../../themes'; 4 | 5 | export default { 6 | 200: weather.rainy, 7 | 300: weather.rainy, 8 | 500: weather.rainy, 9 | 600: weather.rainy, 10 | 700: weather.partlyCloudy, 11 | 800: weather.clear, 12 | 801: weather.partlyCloudy, 13 | 802: weather.partlyCloudy, 14 | 803: weather.partlyCloudy, 15 | 804: weather.partlyCloudy, 16 | }; -------------------------------------------------------------------------------- /client/src/constants/forecast/tides-chart-options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILineOptions, 3 | LINE_TYPE 4 | } from '@ocula/charts'; 5 | 6 | import { 7 | dateFromUnix, 8 | numberRound 9 | } from '@ocula/utilities'; 10 | 11 | import type { 12 | Formatted 13 | } from '../../types/state'; 14 | 15 | import type { 16 | IForecastTideHeight 17 | } from '../../types/weather'; 18 | 19 | type ChartOptions = ILineOptions>; 20 | 21 | export default { 22 | type: LINE_TYPE.spline, 23 | scales: { 24 | x: { 25 | type: 'time', 26 | value: ({ dt }) => dateFromUnix(dt.raw) 27 | }, 28 | y: { 29 | type: 'linear', 30 | ticks: 5, 31 | value: ({ height }) => height.raw 32 | } 33 | }, 34 | labels: { 35 | content: (point, index) => index ? numberRound(point.value.height.raw, 2) : null 36 | }, 37 | colours: { 38 | line: '#47B1FA', 39 | marker: '#47B1FA' 40 | } 41 | } as ChartOptions; -------------------------------------------------------------------------------- /client/src/constants/forecast/unit-of-measure.ts: -------------------------------------------------------------------------------- 1 | import UNITS from '../../enums/forecast/units'; 2 | import OBSERVATION from '../../enums/forecast/observation'; 3 | import UNIT_OF_MEASURE from '../../enums/forecast/unit-of-measure'; 4 | 5 | type UnitOfMeasure = Record 6 | 7 | export default { 8 | [UNITS.metric]: { 9 | [OBSERVATION.temperature]: UNIT_OF_MEASURE.celcius, 10 | [OBSERVATION.pressure]: UNIT_OF_MEASURE.hectopascals, 11 | [OBSERVATION.windSpeed]: UNIT_OF_MEASURE.metresPerSecond, 12 | [OBSERVATION.precipitation]: UNIT_OF_MEASURE.percentage 13 | }, 14 | [UNITS.imperial]: { 15 | [OBSERVATION.temperature]: UNIT_OF_MEASURE.fahrenheit, 16 | [OBSERVATION.pressure]: UNIT_OF_MEASURE.millibars, 17 | [OBSERVATION.windSpeed]: UNIT_OF_MEASURE.milesPerHour, 18 | [OBSERVATION.precipitation]: UNIT_OF_MEASURE.percentage 19 | } 20 | } as Record -------------------------------------------------------------------------------- /client/src/constants/forecast/units.ts: -------------------------------------------------------------------------------- 1 | import UNITS from '../../enums/forecast/units'; 2 | 3 | export default { 4 | [UNITS.metric]: { 5 | label: 'Metric' 6 | }, 7 | [UNITS.imperial]: { 8 | label: 'Imperial' 9 | } 10 | } as const; -------------------------------------------------------------------------------- /client/src/constants/forecast/uv-index.ts: -------------------------------------------------------------------------------- 1 | interface IUVIndex { 2 | id: string; 3 | label: string; 4 | start: number; 5 | colour: string; 6 | }; 7 | 8 | export default [ 9 | { 10 | id: 'low', 11 | label: 'Low', 12 | start: 0, 13 | colour: '#3FA72D' 14 | }, 15 | { 16 | id: 'moderate', 17 | label: 'Moderate', 18 | start: 3, 19 | colour: '#FFF301' 20 | }, 21 | { 22 | id: 'high', 23 | label: 'High', 24 | start: 6, 25 | colour: '#F18B00' 26 | }, 27 | { 28 | id: 'veryHigh', 29 | label: 'Very High', 30 | start: 8, 31 | colour: '#E53110' 32 | }, 33 | { 34 | id: 'extreme', 35 | label: 'Extreme', 36 | start: 11, 37 | colour: '#B467A4' 38 | } 39 | ] as IUVIndex[]; -------------------------------------------------------------------------------- /client/src/controllers/application.ts: -------------------------------------------------------------------------------- 1 | import MAP from '../enums/maps/map'; 2 | 3 | import EVENTS from '../constants/core/events'; 4 | import MODALS from '../constants/core/modals'; 5 | import DRAWERS from '../constants/core/drawers'; 6 | 7 | import eventEmitter from '@ocula/event-emitter'; 8 | 9 | import { 10 | componentsController 11 | } from '@ocula/components'; 12 | 13 | import { 14 | functionDebounce 15 | } from '@ocula/utilities'; 16 | 17 | const resize = functionDebounce(event => eventEmitter.emit(EVENTS.application.resized, event), 300); 18 | 19 | function visibilityChanged() { 20 | if (!document.hidden) { 21 | eventEmitter.emit(EVENTS.application.visible); 22 | } 23 | } 24 | 25 | window.addEventListener('resize', resize) 26 | document.addEventListener('visibilitychange', visibilityChanged); 27 | 28 | export class ApplicationController { 29 | 30 | constructor() { 31 | 32 | } 33 | 34 | async setLocation() { 35 | return componentsController.open(MODALS.locations); 36 | } 37 | 38 | async setMapType(): Promise { 39 | return componentsController.open(DRAWERS.maps); 40 | } 41 | 42 | async notify(title: string, options?: NotificationOptions): Promise { 43 | if (Notification.permission !== 'granted') { 44 | await Notification.requestPermission(); 45 | } 46 | 47 | return new Notification(title, options); 48 | } 49 | 50 | } 51 | 52 | export default new ApplicationController(); -------------------------------------------------------------------------------- /client/src/enums/core/status.ts: -------------------------------------------------------------------------------- 1 | const enum STATUS { 2 | loading = 'loading', 3 | error = 'error' 4 | }; 5 | 6 | export default STATUS; -------------------------------------------------------------------------------- /client/src/enums/forecast/location.ts: -------------------------------------------------------------------------------- 1 | const enum LOCATION { 2 | current = 'current' 3 | }; 4 | 5 | export default LOCATION; -------------------------------------------------------------------------------- /client/src/enums/forecast/observation.ts: -------------------------------------------------------------------------------- 1 | const enum OBSERVATION { 2 | temperature = 'temperature', 3 | precipitation = 'precipitation', 4 | humidity = 'humidity', 5 | sunrise = 'sunrise', 6 | sunset = 'sunset', 7 | pressure = 'pressure', 8 | windSpeed = 'windSpeed', 9 | windDirection = 'windDirection', 10 | }; 11 | 12 | export default OBSERVATION; -------------------------------------------------------------------------------- /client/src/enums/forecast/phase.ts: -------------------------------------------------------------------------------- 1 | const enum PHASE { 2 | day = 'day', 3 | night = 'night' 4 | }; 5 | 6 | export default PHASE; -------------------------------------------------------------------------------- /client/src/enums/forecast/section.ts: -------------------------------------------------------------------------------- 1 | const enum FORECAST_SECTION { 2 | today = 'today', 3 | dailyForecast = 'daily-forecast', 4 | hourlyForecast = 'hourly-forecast', 5 | uvIndex = 'uv-index', 6 | tides = 'tides' 7 | }; 8 | 9 | export default FORECAST_SECTION; -------------------------------------------------------------------------------- /client/src/enums/forecast/trend.ts: -------------------------------------------------------------------------------- 1 | const enum TREND { 2 | temperature = 'temperature', 3 | rainfall = 'rainfallprobability', 4 | wind = 'wind' 5 | }; 6 | 7 | export default TREND; -------------------------------------------------------------------------------- /client/src/enums/forecast/unit-of-measure.ts: -------------------------------------------------------------------------------- 1 | const enum UNIT_OF_MEASURE { 2 | // Distance 3 | millimeters = 'mm', 4 | centimetres = 'cm', 5 | metres = 'm', 6 | kilometres = 'km', 7 | miles = 'mi', 8 | inches = 'in', 9 | 10 | // Speed 11 | millimetresPerHour = 'mm/h', 12 | kilometresPerHour = 'km/h', 13 | metresPerSecond = 'm/s', 14 | inchesPerHour = 'mi/h', 15 | milesPerHour = 'mi/h', 16 | 17 | // Temperature 18 | celcius = '°C', 19 | fahrenheit = '°F', 20 | 21 | // Pressure 22 | hectopascals = 'hPa', 23 | millibars = 'bar', 24 | 25 | // General 26 | percentage = '%' 27 | }; 28 | 29 | export default UNIT_OF_MEASURE; -------------------------------------------------------------------------------- /client/src/enums/forecast/units.ts: -------------------------------------------------------------------------------- 1 | const enum UNITS { 2 | metric = 'metric', 3 | imperial = 'imperial' 4 | }; 5 | 6 | export default UNITS; -------------------------------------------------------------------------------- /client/src/enums/maps/map.ts: -------------------------------------------------------------------------------- 1 | export const enum MAP { 2 | radar = 'radar', 3 | precipitation = 'precipitation', 4 | temperature = 'temperature', 5 | cloud = 'cloud', 6 | wind = 'wind', 7 | pressure = 'pressure', 8 | }; 9 | 10 | export default MAP; -------------------------------------------------------------------------------- /client/src/helpers/get-direction.ts: -------------------------------------------------------------------------------- 1 | import DIRECTIONS from '../constants/forecast/directions'; 2 | 3 | const divisor = 360 / DIRECTIONS.length; 4 | 5 | export default function getDirection(bearing: number): string { 6 | return DIRECTIONS[Math.floor(bearing / divisor)] || 'Unknown'; 7 | } -------------------------------------------------------------------------------- /client/src/helpers/get-figure.ts: -------------------------------------------------------------------------------- 1 | import FIGURE from '../constants/forecast/figure'; 2 | 3 | import { 4 | phase 5 | } from '../store'; 6 | 7 | export default function getFigure(conditionId: number): string { 8 | const figurePhase = FIGURE[phase.value] || FIGURE.day; 9 | 10 | return figurePhase[conditionId] || figurePhase[Math.floor(conditionId / 100) * 100] || figurePhase['800']; 11 | } -------------------------------------------------------------------------------- /client/src/helpers/get-icon.ts: -------------------------------------------------------------------------------- 1 | import ICON from '../constants/forecast/icon'; 2 | import PHASE from '../enums/forecast/phase'; 3 | 4 | import getPhase from './get-phase'; 5 | 6 | export default function getIcon(conditionId: number, timestamp?: number): string { 7 | let phase = PHASE.day; 8 | 9 | if (timestamp) { 10 | phase = getPhase(timestamp); 11 | } 12 | 13 | const phaseIcon = ICON[phase]; 14 | 15 | return phaseIcon[conditionId] || phaseIcon[Math.floor(conditionId / 100) * 100] || phaseIcon['800']; 16 | } -------------------------------------------------------------------------------- /client/src/helpers/get-phase.ts: -------------------------------------------------------------------------------- 1 | import PHASE from '../enums/forecast/phase'; 2 | 3 | import { 4 | state 5 | } from '../store'; 6 | 7 | export default function getPhase(timestamp: number): PHASE { 8 | if (!state.forecast) { 9 | return PHASE.day; 10 | } 11 | 12 | const isDay = state.forecast.daily.some(({ sunrise, sunset }) => { 13 | return timestamp > sunrise && timestamp < sunset; 14 | }); 15 | 16 | return isDay ? PHASE.day : PHASE.night; 17 | } -------------------------------------------------------------------------------- /client/src/helpers/set-theme-meta.ts: -------------------------------------------------------------------------------- 1 | import { 2 | domSetMeta 3 | } from '@ocula/utilities'; 4 | 5 | export default function setThemeMeta(colour: string): void { 6 | domSetMeta('theme-color', colour); 7 | } -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import start from './startup'; 2 | 3 | export default start(); -------------------------------------------------------------------------------- /client/src/routes/error.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/routes/error/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../constants/core/routes'; 2 | 3 | import Index from './index.vue'; 4 | import NotFound from './not-found.vue'; 5 | 6 | import type { 7 | RouteRecordRaw 8 | } from '@ocula/router'; 9 | 10 | export default [ 11 | { 12 | path: '', 13 | name: ROUTES.error.index, 14 | component: Index, 15 | }, 16 | { 17 | path: 'not-found', 18 | name: ROUTES.error.notFound, 19 | component: NotFound, 20 | } 21 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/error/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/routes/error/not-found.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/routes/forecast.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /client/src/routes/forecast/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../constants/core/routes'; 2 | 3 | import type { 4 | RouteRecordRaw 5 | } from '@ocula/router'; 6 | 7 | import { 8 | defineAsyncComponent 9 | } from 'vue'; 10 | 11 | export default [ 12 | { 13 | path: '', 14 | name: ROUTES.forecast.index, 15 | component: defineAsyncComponent(() => import(/* webpackChunkName: 'forecast' */ './index.vue')) 16 | } 17 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import Forecast from './forecast.vue'; 2 | import Maps from './maps.vue'; 3 | import Settings from './settings.vue'; 4 | import Error from './error.vue'; 5 | 6 | import forecast from './forecast'; 7 | import maps from './maps'; 8 | import settings from './settings'; 9 | import error from './error'; 10 | 11 | import type { 12 | RouteRecordRaw 13 | } from '@ocula/router'; 14 | 15 | export default [ 16 | { 17 | path: '/forecast', 18 | alias: '/', 19 | component: Forecast, 20 | children: forecast 21 | }, 22 | { 23 | path: '/maps', 24 | component: Maps, 25 | children: maps 26 | }, 27 | { 28 | path: '/settings', 29 | component: Settings, 30 | children: settings 31 | }, 32 | { 33 | path: '/error', 34 | component: Error, 35 | children: error 36 | }, 37 | { 38 | path: '/:catchAll(.*)', 39 | redirect: '/error/not-found' 40 | } 41 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/maps.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /client/src/routes/maps/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../constants/core/routes'; 2 | 3 | import { 4 | defineAsyncComponent 5 | } from 'vue'; 6 | 7 | import type { 8 | RouteRecordRaw 9 | } from '@ocula/router'; 10 | 11 | export default [ 12 | { 13 | path: ':type?', 14 | name: ROUTES.maps.index, 15 | props: true, 16 | component: defineAsyncComponent(() => import(/* webpackChunkName: 'maps' */ './index.vue')) 17 | } 18 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/settings.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /client/src/routes/settings/forecast/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../../constants/core/routes'; 2 | 3 | import Locations from './locations.vue'; 4 | import Sections from './sections.vue'; 5 | 6 | import type { 7 | RouteRecordRaw 8 | } from '@ocula/router'; 9 | 10 | export default [ 11 | { 12 | path: 'forecast/locations', 13 | name: ROUTES.settings.forecast.locations, 14 | component: Locations 15 | }, 16 | { 17 | path: 'forecast/sections', 18 | name: ROUTES.settings.forecast.sections, 19 | component: Sections 20 | } 21 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/settings/forecast/locations.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 51 | 52 | -------------------------------------------------------------------------------- /client/src/routes/settings/general/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../../constants/core/routes'; 2 | 3 | import Theme from './theme.vue'; 4 | import About from './about.vue'; 5 | 6 | import type { 7 | RouteRecordRaw 8 | } from '@ocula/router'; 9 | 10 | export default [ 11 | { 12 | path: 'general/theme', 13 | name: ROUTES.settings.general.theme, 14 | component: Theme 15 | }, 16 | { 17 | path: 'general/about', 18 | name: ROUTES.settings.general.about, 19 | component: About 20 | } 21 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/settings/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../constants/core/routes'; 2 | 3 | import Index from './index.vue'; 4 | 5 | import forecast from './forecast'; 6 | import general from './general'; 7 | import maps from './maps'; 8 | 9 | import type { 10 | RouteRecordRaw 11 | } from '@ocula/router'; 12 | 13 | export default [ 14 | { 15 | path: '', 16 | name: ROUTES.settings.index, 17 | component: Index 18 | }, 19 | 20 | ...forecast, 21 | ...maps, 22 | ...general 23 | 24 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/routes/settings/maps/index.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../../../constants/core/routes'; 2 | 3 | import Display from './display.vue'; 4 | 5 | import type { 6 | RouteRecordRaw 7 | } from '@ocula/router'; 8 | 9 | export default [ 10 | { 11 | path: 'maps/display', 12 | name: ROUTES.settings.maps.display, 13 | component: Display 14 | } 15 | ] as RouteRecordRaw[]; -------------------------------------------------------------------------------- /client/src/services/location.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILocation 3 | } from '../types/location'; 4 | 5 | export async function searchLocations(query: string): Promise { 6 | const response = await fetch(`/api/location/search?query=${query}`); 7 | 8 | return response.json(); 9 | } 10 | 11 | export async function getLocation(latitude: number, longitude: number): Promise { 12 | const response = await fetch(`/api/location/coordinates?latitude=${latitude}&longitude=${longitude}`); 13 | 14 | return response.json(); 15 | } -------------------------------------------------------------------------------- /client/src/services/weather.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IForecast 3 | } from '../types/weather'; 4 | 5 | export async function getForecast(latitude: number, longitude: number, units?: string): Promise { 6 | const response = await fetch(`/api/weather/forecast?latitude=${latitude}&longitude=${longitude}&units=${units}`); 7 | 8 | return response.json(); 9 | } 10 | -------------------------------------------------------------------------------- /client/src/startup/application.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createApp 3 | } from 'vue'; 4 | 5 | import App from '../app.vue'; 6 | 7 | export default function initialiseApplication() { 8 | return createApp(App); 9 | } -------------------------------------------------------------------------------- /client/src/startup/components.ts: -------------------------------------------------------------------------------- 1 | import Components from '@ocula/components'; 2 | 3 | import type { 4 | App 5 | } from 'vue'; 6 | 7 | export default function initialiseComponents(application: App) { 8 | return application.use(Components); 9 | } -------------------------------------------------------------------------------- /client/src/startup/index.ts: -------------------------------------------------------------------------------- 1 | import './vendor'; 2 | 3 | import initialiseComponents from './components'; 4 | import initialiseRouter from './router'; 5 | import initialiseApplication from './application'; 6 | import initialiseState from './state'; 7 | import initialiseLogging from './logging'; 8 | import initialiseWorker from './worker'; 9 | 10 | export default async function start() { 11 | const application = initialiseApplication(); 12 | 13 | const router = initialiseRouter(application); 14 | 15 | initialiseState(application); 16 | initialiseComponents(application); 17 | 18 | initialiseWorker(); 19 | initialiseLogging(); 20 | 21 | await router.isReady(); 22 | 23 | application.mount('body'); 24 | 25 | return { 26 | router, 27 | application 28 | }; 29 | } -------------------------------------------------------------------------------- /client/src/startup/logging.ts: -------------------------------------------------------------------------------- 1 | import { 2 | envIsProduction 3 | } from '@ocula/utilities'; 4 | 5 | import { 6 | init 7 | } from '@sentry/browser'; 8 | 9 | 10 | // import { 11 | // Vue as SentryVue 12 | // } from '@sentry/integrations'; 13 | 14 | export default function initialiseLogging() { 15 | init({ 16 | enabled: envIsProduction, 17 | dsn: process.env.SENTRY_DSN, 18 | // integrations: [ 19 | // new SentryVue({ 20 | // Vue, 21 | // attachProps: true 22 | // }) 23 | // ] 24 | }); 25 | } -------------------------------------------------------------------------------- /client/src/startup/router.ts: -------------------------------------------------------------------------------- 1 | import ROUTES from '../constants/core/routes'; 2 | 3 | import routes from '../routes'; 4 | 5 | import setThemeMeta from '../helpers/set-theme-meta'; 6 | 7 | import Router, { 8 | router 9 | } from '@ocula/router'; 10 | 11 | import type { 12 | App 13 | } from 'vue'; 14 | 15 | import { 16 | theme 17 | } from '../store'; 18 | 19 | declare global { 20 | interface Window { 21 | gtag?(key: string, trackingId: string, meta: any): void 22 | } 23 | } 24 | 25 | export default function initialiseRouter(application: App) { 26 | application.use(Router, routes); 27 | 28 | router.beforeEach((to, from, next) => { 29 | if (!theme.value) { 30 | return next(); 31 | } 32 | 33 | const isForecast = to.matched.some(({ name }) => name === ROUTES.forecast.index); 34 | 35 | let { 36 | colour 37 | } = theme.value.core; 38 | 39 | if (isForecast) { 40 | colour = theme.value.weather.colour || colour; 41 | } 42 | 43 | setThemeMeta(colour); 44 | 45 | next(); 46 | }); 47 | 48 | if ('gtag' in window) { 49 | router.afterEach(to => window.gtag('config', process.env.GA_TRACKING_ID, { 50 | 'page_path': to.path 51 | })); 52 | } 53 | 54 | return router; 55 | } -------------------------------------------------------------------------------- /client/src/startup/state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | plugin 3 | } from '@ocula/state'; 4 | 5 | import type { 6 | App 7 | } from 'vue'; 8 | 9 | export default function initialiseComponents(application: App) { 10 | return application.use(plugin); 11 | } -------------------------------------------------------------------------------- /client/src/startup/vendor.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import 'regenerator-runtime/runtime'; -------------------------------------------------------------------------------- /client/src/startup/worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Workbox, 3 | messageSW 4 | } from 'workbox-window'; 5 | 6 | import { 7 | clearData 8 | } from '../store/helpers/storage'; 9 | 10 | import { 11 | componentsController 12 | } from '@ocula/components'; 13 | 14 | import { 15 | envIsDevelopment 16 | } from '@ocula/utilities'; 17 | 18 | import type { 19 | WorkboxLifecycleWaitingEvent 20 | } from 'workbox-window/utils/WorkboxEvent'; 21 | 22 | export default async function initialiseWorker() { 23 | if (envIsDevelopment || !navigator.serviceWorker) { 24 | return; 25 | } 26 | 27 | const workbox = new Workbox('/service-worker.js'); 28 | 29 | let registration: ServiceWorkerRegistration; 30 | 31 | async function handleUpdate(event: WorkboxLifecycleWaitingEvent) { 32 | try { 33 | await componentsController.confirm({ 34 | message: 'An update to Ocula has been installed. Would you like to reload now and complete the update?', 35 | confirmLabel: 'Yes, update', 36 | cancelLabel: 'Later' 37 | }); 38 | 39 | workbox.addEventListener('controlling', () => { 40 | clearData(); 41 | window.location.reload(); 42 | }); 43 | 44 | if (registration && registration.waiting) { 45 | messageSW(registration.waiting, { 46 | type: 'SKIP_WAITING' 47 | }); 48 | } 49 | } catch { 50 | // do nothing 51 | } 52 | } 53 | 54 | workbox.addEventListener('waiting', handleUpdate); 55 | workbox.addEventListener('externalwaiting', handleUpdate); 56 | 57 | registration = await workbox.register(); 58 | } -------------------------------------------------------------------------------- /client/src/static/.well-known/somthing.txt: -------------------------------------------------------------------------------- 1 | sdfgdsf -------------------------------------------------------------------------------- /client/src/static/robots.txt: -------------------------------------------------------------------------------- 1 | Sitemap: https://app.ocula.io/sitemap.xml -------------------------------------------------------------------------------- /client/src/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://app.ocula.io 5 | 1.0 6 | 7 | 8 | https://app.ocula.io/maps 9 | 0.8 10 | 11 | -------------------------------------------------------------------------------- /client/src/store/actions/add-location.ts: -------------------------------------------------------------------------------- 1 | import updateSettings from './update-settings'; 2 | import setLocation from './set-location'; 3 | 4 | import type { 5 | ILocation 6 | } from '../../types/location'; 7 | 8 | import { 9 | state 10 | } from '../store'; 11 | 12 | import { 13 | arrayUniqueBy 14 | } from '@ocula/utilities'; 15 | 16 | export default function addLocation(location: ILocation, setAsCurrent: boolean = false): void { 17 | const { 18 | locations 19 | } = state.settings; 20 | 21 | updateSettings({ 22 | locations: arrayUniqueBy([...locations, location], ({ id }) => id) 23 | }); 24 | 25 | if (setAsCurrent) { 26 | setLocation(location); 27 | } 28 | } -------------------------------------------------------------------------------- /client/src/store/actions/load-forecast.ts: -------------------------------------------------------------------------------- 1 | import { 2 | state, 3 | mutate 4 | } from '../store'; 5 | 6 | import { 7 | getForecast 8 | } from '../../services/weather'; 9 | 10 | export default async function loadForecast(latitude: number, longitude: number) { 11 | const { 12 | units 13 | } = state.settings; 14 | 15 | const forecast = await getForecast(latitude, longitude, units); 16 | 17 | mutate('set-forecast', state => state.forecast = forecast); 18 | } -------------------------------------------------------------------------------- /client/src/store/actions/load-location.ts: -------------------------------------------------------------------------------- 1 | import LOCATION from '../../enums/forecast/location'; 2 | 3 | import { 4 | state, 5 | mutate 6 | } from '../store'; 7 | 8 | import { 9 | getPosition 10 | } from '../helpers/location'; 11 | 12 | import { 13 | getLocation 14 | } from '../../services/location'; 15 | 16 | import type { 17 | ILocation 18 | } from '../../types/location'; 19 | 20 | export default async function loadLocation(): Promise { 21 | const { 22 | location: savedLocation 23 | } = state.settings; 24 | 25 | let location = savedLocation; 26 | 27 | if (location === LOCATION.current) { 28 | const { 29 | latitude, 30 | longitude 31 | } = await getPosition(); 32 | 33 | if (!latitude || !longitude) { 34 | return; 35 | } 36 | 37 | location = await getLocation(latitude, longitude); 38 | } 39 | 40 | mutate('set-location', state => state.location = location as ILocation); 41 | 42 | return location; 43 | } -------------------------------------------------------------------------------- /client/src/store/actions/move-section.ts: -------------------------------------------------------------------------------- 1 | import FORECAST_SECTION from '../../enums/forecast/section'; 2 | 3 | import updateSettings from './update-settings'; 4 | 5 | import { 6 | state 7 | } from '../store'; 8 | 9 | import { 10 | arraySwapBy 11 | } from '@ocula/utilities'; 12 | 13 | export default function moveSection(type: FORECAST_SECTION, offset: number = 1): void { 14 | let { 15 | sections 16 | } = state.settings.forecast; 17 | 18 | const index = sections.findIndex(section => section.type === type); 19 | 20 | sections = arraySwapBy(sections, index, index + offset); 21 | 22 | updateSettings({ 23 | forecast: { 24 | sections 25 | } 26 | }); 27 | } -------------------------------------------------------------------------------- /client/src/store/actions/remove-location.ts: -------------------------------------------------------------------------------- 1 | import updateSettings from './update-settings'; 2 | 3 | import type { 4 | ILocation 5 | } from '../../types/location'; 6 | 7 | import { 8 | state 9 | } from '../store'; 10 | 11 | export default function removeLocation(location: ILocation): void { 12 | const { 13 | locations 14 | } = state.settings; 15 | 16 | updateSettings({ 17 | locations: locations.filter(({ id }) => id !== location.id) 18 | }); 19 | } -------------------------------------------------------------------------------- /client/src/store/actions/reset-settings.ts: -------------------------------------------------------------------------------- 1 | import SETTINGS from '../../constants/core/settings'; 2 | 3 | import { 4 | mutate 5 | } from '../store'; 6 | 7 | import { 8 | saveSettings, 9 | clearData 10 | } from '../helpers/storage'; 11 | 12 | export default function resetSettings() { 13 | mutate('reset-settings', state => state.settings = SETTINGS); 14 | saveSettings(SETTINGS); 15 | clearData(); 16 | } -------------------------------------------------------------------------------- /client/src/store/actions/search-locations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | searchLocations 3 | } from '../../services/location'; 4 | 5 | import type { 6 | ILocation 7 | } from '../../types/location'; 8 | 9 | export default async function (query: string): Promise { 10 | return searchLocations(query); 11 | } -------------------------------------------------------------------------------- /client/src/store/actions/set-current-location.ts: -------------------------------------------------------------------------------- 1 | import LOCATION from '../../enums/forecast/location'; 2 | 3 | import setLocation from './set-location'; 4 | 5 | export default function setCurrentLocation(): void { 6 | setLocation(LOCATION.current); 7 | } -------------------------------------------------------------------------------- /client/src/store/actions/set-location.ts: -------------------------------------------------------------------------------- 1 | import LOCATION from '../../enums/forecast/location'; 2 | import EVENTS from '../../constants/core/events'; 3 | 4 | import setLastUpdated from '../mutations/set-last-updated'; 5 | 6 | import updateSettings from './update-settings'; 7 | 8 | import eventEmitter from '@ocula/event-emitter'; 9 | 10 | import { 11 | typeIsNil 12 | } from '@ocula/utilities'; 13 | 14 | import type { 15 | ILocation 16 | } from '../../types/location'; 17 | 18 | export default function setLocation(location: ILocation | LOCATION): void { 19 | if (typeIsNil(location)) { 20 | return; 21 | } 22 | 23 | setLastUpdated(null); 24 | 25 | updateSettings({ 26 | location 27 | }); 28 | 29 | eventEmitter.emit(EVENTS.location.set, location); 30 | } -------------------------------------------------------------------------------- /client/src/store/actions/set-section-visibility.ts: -------------------------------------------------------------------------------- 1 | import FORECAST_SECTION from '../../enums/forecast/section'; 2 | 3 | import updateSettings from './update-settings'; 4 | 5 | import { 6 | state 7 | } from '../store'; 8 | 9 | export default function setSectionVisibility(type: FORECAST_SECTION, isVisible = true): void { 10 | let { 11 | sections 12 | } = state.settings.forecast; 13 | 14 | sections = sections.map(section => { 15 | const visible = section.type === type ? isVisible : section.visible; 16 | 17 | return { 18 | ...section, 19 | visible 20 | }; 21 | }); 22 | 23 | updateSettings({ 24 | forecast: { 25 | sections 26 | } 27 | }); 28 | } -------------------------------------------------------------------------------- /client/src/store/actions/update-settings.ts: -------------------------------------------------------------------------------- 1 | import setSettings from '../mutations/set-settings'; 2 | 3 | import type { 4 | ISettings 5 | } from '../../types/storage'; 6 | 7 | export default function updateSettings(settings: Partial): void { 8 | setSettings(settings); 9 | } -------------------------------------------------------------------------------- /client/src/store/actions/update.ts: -------------------------------------------------------------------------------- 1 | import STATUS from '../../enums/core/status'; 2 | 3 | import GLOBAL from '../../constants/core/global'; 4 | 5 | import setStatus from '../mutations/set-status'; 6 | import setLastUpdated from '../mutations/set-last-updated'; 7 | 8 | import loadLocation from './load-location'; 9 | import loadForecast from './load-forecast'; 10 | 11 | import { 12 | state 13 | } from '../store'; 14 | 15 | import { 16 | saveData 17 | } from '../helpers/storage'; 18 | 19 | export default async function update(force: boolean = false) { 20 | const lastUpdated = state.lastUpdated; 21 | 22 | if (!force && lastUpdated && Date.now() - +lastUpdated < GLOBAL.updateThreshold) { 23 | return; 24 | } 25 | 26 | setStatus(STATUS.loading); 27 | 28 | try { 29 | const { 30 | latitude, 31 | longitude 32 | } = await loadLocation(); 33 | 34 | await loadForecast(latitude, longitude); 35 | 36 | setLastUpdated(); 37 | 38 | const { 39 | lastUpdated, 40 | location, 41 | forecast 42 | } = state; 43 | 44 | saveData({ 45 | lastUpdated, 46 | location, 47 | forecast 48 | }); 49 | } catch (error) { 50 | setStatus(STATUS.error); 51 | } 52 | 53 | setStatus(null); 54 | } -------------------------------------------------------------------------------- /client/src/store/getters/forecast.ts: -------------------------------------------------------------------------------- 1 | import UNITS from '../../enums/forecast/units'; 2 | import FORMATS from '../../constants/forecast/formats'; 3 | 4 | import { 5 | defaultFormatter 6 | } from '../../constants/forecast/formatters'; 7 | 8 | import { 9 | getter 10 | } from '../store'; 11 | 12 | import { 13 | objectTransform 14 | } from '@ocula/utilities'; 15 | 16 | import type { 17 | Formatted, 18 | IMappedForecast 19 | } from '../../types/state'; 20 | 21 | import type { 22 | IForecast 23 | } from '../../types/weather'; 24 | 25 | export default getter>(state => { 26 | const { 27 | forecast, 28 | settings 29 | } = state; 30 | 31 | if (!forecast) { 32 | return; 33 | } 34 | 35 | const format = FORMATS[settings.units] || FORMATS[UNITS.metric]; 36 | 37 | const { 38 | daily, 39 | ...other 40 | } = objectTransform>(forecast, format, defaultFormatter); 41 | 42 | const today = daily.shift(); 43 | 44 | return { 45 | ...other, 46 | today, 47 | daily 48 | }; 49 | }); -------------------------------------------------------------------------------- /client/src/store/getters/format.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getter 3 | } from '../store'; 4 | 5 | import { 6 | dateFormat, 7 | dateUtcToZoned 8 | } from '@ocula/utilities'; 9 | 10 | import type { 11 | IFormatter 12 | } from '../../types/state'; 13 | 14 | export default getter(({ forecast }) => { 15 | let options; 16 | let converter = value => value; 17 | 18 | const output = { 19 | date: (value: Date, format: string = 'EEEE, d MMM') => dateFormat(converter(value), format, options), 20 | time: (value: Date, format: string = 'h:mm a') => dateFormat(converter(value), format, options).toLowerCase() 21 | }; 22 | 23 | if (forecast && forecast.timezone) { 24 | converter = value => dateUtcToZoned(value, forecast.timezone); 25 | 26 | options = { 27 | timeZone: forecast.timezone 28 | }; 29 | } 30 | 31 | return output; 32 | }); -------------------------------------------------------------------------------- /client/src/store/getters/phase.ts: -------------------------------------------------------------------------------- 1 | import getPhase from '../../helpers/get-phase'; 2 | 3 | import { 4 | getter 5 | } from '../store'; 6 | 7 | import { 8 | dateToUnix 9 | } from '@ocula/utilities'; 10 | 11 | export default getter(() => getPhase(dateToUnix(new Date()))); -------------------------------------------------------------------------------- /client/src/store/getters/theme.ts: -------------------------------------------------------------------------------- 1 | import THEME from '../../constants/forecast/theme'; 2 | 3 | import phase from './phase'; 4 | 5 | import { 6 | getter 7 | } from '../store'; 8 | 9 | import { 10 | core, 11 | weather 12 | } from '../../themes'; 13 | 14 | import type { 15 | ITheme 16 | } from '../../types/themes'; 17 | 18 | import { 19 | typeIsPlainObject 20 | } from '@ocula/utilities'; 21 | 22 | function getPhasedTheme(theme: ITheme): ITheme { 23 | let { 24 | colour, 25 | mapStyle, 26 | ...value 27 | } = theme; 28 | 29 | if (typeIsPlainObject(colour)) { 30 | colour = colour[phase.value] 31 | } 32 | 33 | if (typeIsPlainObject(mapStyle)) { 34 | mapStyle = mapStyle[phase.value]; 35 | } 36 | 37 | return { 38 | ...value, 39 | colour, 40 | mapStyle 41 | }; 42 | } 43 | 44 | export default getter(({ settings, forecast }) => { 45 | const { 46 | theme 47 | } = settings; 48 | 49 | let coreTheme = core[theme] || core.default; 50 | let weatherTheme = weather.default; 51 | 52 | coreTheme = getPhasedTheme(coreTheme); 53 | 54 | if (forecast && forecast.current) { 55 | const conditionId = forecast.current.weather[0].id; 56 | 57 | weatherTheme = THEME[conditionId] || THEME[Math.floor(conditionId / 100) * 100] || weather.default; 58 | weatherTheme = getPhasedTheme(weatherTheme); 59 | } 60 | 61 | return { 62 | core: coreTheme, 63 | weather: weatherTheme 64 | }; 65 | }); -------------------------------------------------------------------------------- /client/src/store/getters/unit-of-measure.ts: -------------------------------------------------------------------------------- 1 | import UNITS from '../../enums/forecast/units'; 2 | import UNIT_OF_MEASURE from '../../constants/forecast/unit-of-measure'; 3 | 4 | import { 5 | getter 6 | } from '../store'; 7 | 8 | export default getter(({ settings }) => UNIT_OF_MEASURE[settings.units || UNITS.metric]) -------------------------------------------------------------------------------- /client/src/store/helpers/location.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ICoordinate 3 | } from '../../types/location'; 4 | 5 | export async function getPosition(): Promise { 6 | const position: Position = await new Promise((resolve, reject) => { 7 | navigator.geolocation.getCurrentPosition(resolve, reject, { 8 | maximumAge: 0, 9 | enableHighAccuracy: true 10 | }); 11 | }); 12 | 13 | if (position) { 14 | return position.coords; 15 | } 16 | 17 | return { 18 | latitude: 0, 19 | longitude: 0, 20 | }; 21 | } -------------------------------------------------------------------------------- /client/src/store/helpers/storage.ts: -------------------------------------------------------------------------------- 1 | import DATA from '../../constants/core/data'; 2 | import SETTINGS from '../../constants/core/settings'; 3 | import MIGRATIONS from '../../constants/core/migrations'; 4 | import STORAGE_KEYS from '../../constants/core/storage-keys'; 5 | 6 | import { 7 | objectMerge, objectMergeWith, typeIsArray 8 | } from '@ocula/utilities'; 9 | 10 | import type { 11 | ISettings, 12 | IStoredData 13 | } from '../../types/storage'; 14 | 15 | export function getSettings(): ISettings { 16 | const storedSettings = localStorage.getItem(STORAGE_KEYS.settings); 17 | 18 | if (!storedSettings) { 19 | return SETTINGS; 20 | } 21 | 22 | let settings = JSON.parse(storedSettings) as ISettings; 23 | 24 | settings = objectMergeWith(SETTINGS, settings, (obj, src) => { 25 | if (typeIsArray(obj)) { 26 | return src; 27 | } 28 | }); 29 | 30 | if (settings.version < SETTINGS.version && settings.version in MIGRATIONS) { 31 | try { 32 | settings = MIGRATIONS[settings.version.toString()](settings); 33 | settings.version = SETTINGS.version; 34 | 35 | saveSettings(settings); 36 | } catch (error) { 37 | console.warn('Failed to migrate settings'); 38 | } 39 | } 40 | 41 | return settings; 42 | } 43 | 44 | export function getData(): IStoredData { 45 | const storedData = localStorage.getItem(STORAGE_KEYS.data); 46 | 47 | if (!storedData) { 48 | return DATA; 49 | } 50 | 51 | const data = JSON.parse(storedData) as IStoredData; 52 | 53 | data.lastUpdated = new Date(data.lastUpdated); 54 | 55 | return objectMerge(DATA, data); 56 | } 57 | 58 | export function saveSettings(settings: ISettings): void { 59 | localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings)); 60 | } 61 | 62 | export function saveData({ lastUpdated, location, forecast }: IStoredData): void { 63 | localStorage.setItem(STORAGE_KEYS.data, JSON.stringify({ 64 | location, 65 | forecast, 66 | lastUpdated 67 | })); 68 | } 69 | 70 | export function clearData(): void { 71 | localStorage.removeItem(STORAGE_KEYS.data); 72 | } -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export { state } from './store'; 2 | 3 | export { default as forecast } from './getters/forecast'; 4 | export { default as phase } from './getters/phase'; 5 | export { default as format } from './getters/format'; 6 | export { default as theme } from './getters/theme'; 7 | export { default as unitOfMeasure } from './getters/unit-of-measure'; 8 | 9 | export { default as update } from './actions/update'; 10 | export { default as loadLocation } from './actions/load-location'; 11 | export { default as loadForecast } from './actions/load-forecast'; 12 | export { default as searchLocations } from './actions/search-locations'; 13 | export { default as addLocation } from './actions/add-location'; 14 | export { default as removeLocation } from './actions/remove-location'; 15 | export { default as setLocation } from './actions/set-location'; 16 | export { default as setCurrentLocation } from './actions/set-current-location'; 17 | export { default as moveSection } from './actions/move-section'; 18 | export { default as setSectionVisibility } from './actions/set-section-visibility'; 19 | export { default as updateSettings } from './actions/update-settings'; 20 | export { default as resetSettings } from './actions/reset-settings'; -------------------------------------------------------------------------------- /client/src/store/mutations/set-last-updated.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mutate 3 | } from '../store'; 4 | 5 | export default function setLastUpdated(value: Date | null = new Date()): void { 6 | mutate('set-last-updated', state => state.lastUpdated = value); 7 | } -------------------------------------------------------------------------------- /client/src/store/mutations/set-settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | state, 3 | mutate 4 | } from '../store'; 5 | 6 | import { 7 | saveSettings 8 | } from '../helpers/storage'; 9 | 10 | import type { 11 | ISettings 12 | } from '../../types/storage'; 13 | 14 | export default function setSettings(value: Partial) { 15 | const settings = { 16 | ...state.settings, 17 | ...value 18 | }; 19 | 20 | mutate('set-settings', state => state.settings = settings); 21 | saveSettings(settings); 22 | } -------------------------------------------------------------------------------- /client/src/store/mutations/set-status.ts: -------------------------------------------------------------------------------- 1 | import STATUS from '../../enums/core/status'; 2 | 3 | import { 4 | mutate 5 | } from '../store'; 6 | 7 | export default function setStatus(status: STATUS = null): void { 8 | mutate('set-status', state => state.status = status); 9 | } -------------------------------------------------------------------------------- /client/src/store/state/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getSettings, 3 | getData 4 | } from '../helpers/storage'; 5 | 6 | import { 7 | IState 8 | } from '../../types/state'; 9 | 10 | function getState(): IState { 11 | const settings = getSettings(); 12 | 13 | const { 14 | location, 15 | forecast, 16 | lastUpdated 17 | } = getData(); 18 | 19 | return { 20 | settings, 21 | location, 22 | forecast, 23 | lastUpdated, 24 | status: null 25 | }; 26 | } 27 | 28 | export default getState(); -------------------------------------------------------------------------------- /client/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import createStore from '@ocula/state'; 2 | 3 | import _state from './state'; 4 | 5 | export const { 6 | state, 7 | getter, 8 | mutate 9 | } = createStore('ocula', _state); -------------------------------------------------------------------------------- /client/src/themes/core/dark/_dark.scss: -------------------------------------------------------------------------------- 1 | @mixin dark { 2 | --font__colour: #F7F7F7; 3 | --background__colour: #333333; 4 | --background__colour--hover: #444444; 5 | --border__colour: #777777; 6 | } -------------------------------------------------------------------------------- /client/src/themes/core/dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./_dark"; 2 | 3 | .theme--dark { 4 | @include dark; 5 | } -------------------------------------------------------------------------------- /client/src/themes/core/dark/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import type { 4 | ITheme 5 | } from '../../../types/themes'; 6 | 7 | export default { 8 | id: 'dark', 9 | name: 'Dark', 10 | colour: '#333333', 11 | class: 'theme--dark', 12 | mapStyle: 'dark' 13 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/core/default/index.scss: -------------------------------------------------------------------------------- 1 | @import "../light/_light"; 2 | @import "../dark/_dark"; 3 | 4 | .theme--default { 5 | @include light; 6 | } 7 | 8 | .phase--night { 9 | 10 | &.theme--default { 11 | @include dark; 12 | } 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | 17 | .theme--default { 18 | @include dark; 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /client/src/themes/core/default/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import PHASE from '../../../enums/forecast/phase'; 4 | 5 | import type { 6 | ITheme 7 | } from '../../../types/themes'; 8 | 9 | export default { 10 | id: 'default', 11 | name: 'Default', 12 | colour: { 13 | [PHASE.night as string]: '#333333' 14 | }, 15 | class: 'theme--default', 16 | mapStyle: { 17 | [PHASE.night as string]: 'dark' 18 | } 19 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/core/index.ts: -------------------------------------------------------------------------------- 1 | import _default from './default'; 2 | import light from './light'; 3 | import dark from './dark'; 4 | 5 | export default { 6 | default: _default, 7 | light, 8 | dark 9 | }; -------------------------------------------------------------------------------- /client/src/themes/core/light/_light.scss: -------------------------------------------------------------------------------- 1 | @mixin light { 2 | --font__colour: #353539; 3 | --background__colour: #FFFFFF; 4 | --background__colour--hover: #EEEEEE; 5 | --border__colour: #EDEDED; 6 | } -------------------------------------------------------------------------------- /client/src/themes/core/light/index.scss: -------------------------------------------------------------------------------- 1 | @import "./_light"; 2 | 3 | .theme--light { 4 | @include light; 5 | } -------------------------------------------------------------------------------- /client/src/themes/core/light/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import type { 4 | ITheme 5 | } from '../../../types/themes'; 6 | 7 | export default { 8 | id: 'light', 9 | name: 'Light', 10 | colour: '#FFFFFF', 11 | class: 'theme--light', 12 | mapStyle: 'light' 13 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as core } from './core'; 2 | export { default as weather } from './weather'; -------------------------------------------------------------------------------- /client/src/themes/weather/clear/index.scss: -------------------------------------------------------------------------------- 1 | .theme--weather-clear { 2 | --font__colour--weather: #FFFFFF; 3 | --background__colour--weather: #5D9BE5; 4 | } 5 | 6 | .phase--night { 7 | 8 | & .theme--weather-clear { 9 | --background__colour--weather: #44296A; 10 | } 11 | } -------------------------------------------------------------------------------- /client/src/themes/weather/clear/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import PHASE from '../../../enums/forecast/phase'; 4 | 5 | import type { 6 | ITheme 7 | } from '../../../types/themes'; 8 | 9 | export default { 10 | id: 'weather-clear', 11 | name: 'Clear', 12 | colour: { 13 | [PHASE.day as string]: '#5D9BE5', 14 | [PHASE.night as string]: '#44296A' 15 | }, 16 | class: 'theme--weather-clear', 17 | mapStyle: { 18 | [PHASE.night as string]: 'dark' 19 | } 20 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/weather/default/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ITheme 3 | } from '../../../types/themes'; 4 | 5 | export default { 6 | id: 'weather-default', 7 | name: 'Default', 8 | colour: '', 9 | class: '' 10 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/weather/index.ts: -------------------------------------------------------------------------------- 1 | import _default from './default'; 2 | import clear from './clear'; 3 | import partlyCloudy from './partly-cloudy'; 4 | import rainy from './rainy'; 5 | 6 | export default { 7 | default: _default, 8 | clear, 9 | partlyCloudy, 10 | rainy 11 | }; -------------------------------------------------------------------------------- /client/src/themes/weather/partly-cloudy/index.scss: -------------------------------------------------------------------------------- 1 | .theme--weather-partly-cloudy { 2 | --font__colour--weather: #FFFFFF; 3 | --background__colour--weather: #5D9BE5; 4 | } 5 | 6 | .phase--night { 7 | 8 | & .theme--weather-partly-cloudy { 9 | --background__colour--weather: #44296A; 10 | } 11 | } -------------------------------------------------------------------------------- /client/src/themes/weather/partly-cloudy/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import PHASE from '../../../enums/forecast/phase'; 4 | 5 | import type { 6 | ITheme 7 | } from '../../../types/themes'; 8 | 9 | export default { 10 | id: 'weather-partly-cloudy', 11 | name: 'Partly Cloudy', 12 | colour: { 13 | [PHASE.day as string]: '#5D9BE5', 14 | [PHASE.night as string]: '#44296A' 15 | }, 16 | class: 'theme--weather-partly-cloudy', 17 | mapStyle: { 18 | [PHASE.night as string]: 'dark' 19 | } 20 | } as ITheme; -------------------------------------------------------------------------------- /client/src/themes/weather/rainy/index.scss: -------------------------------------------------------------------------------- 1 | .theme--weather-rainy { 2 | --font__colour--weather: #FFFFFF; 3 | --background__colour--weather: #AAAAAA; 4 | } -------------------------------------------------------------------------------- /client/src/themes/weather/rainy/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import type { 4 | ITheme 5 | } from '../../../types/themes'; 6 | 7 | export default { 8 | id: 'weather-rainy', 9 | name: 'Rainy', 10 | colour: '#AAAAAA', 11 | class: 'theme--weather-rainy' 12 | } as ITheme; -------------------------------------------------------------------------------- /client/src/types/location.ts: -------------------------------------------------------------------------------- 1 | export interface ICoordinate { 2 | latitude: number; 3 | longitude: number; 4 | } 5 | 6 | export interface ILocation extends ICoordinate { 7 | id: string; 8 | shortName: string; 9 | longName: string; 10 | } -------------------------------------------------------------------------------- /client/src/types/state.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ISettings 3 | } from './storage'; 4 | 5 | import type { 6 | ILocation 7 | } from './location'; 8 | 9 | import type { 10 | IForecast, 11 | IForecastCurrent, 12 | IForecastWeather, 13 | IForecastDay, 14 | IForecastHour 15 | } from './weather'; 16 | import STATUS from '../enums/core/status'; 17 | 18 | export type Formatted = { 19 | [P in keyof T]: T[P] extends string | number ? { 20 | raw: T[P]; 21 | formatted: U 22 | } : Formatted 23 | } 24 | 25 | export interface IState { 26 | status: STATUS; 27 | lastUpdated: Date; 28 | settings: ISettings; 29 | location: ILocation; 30 | forecast: IForecast; 31 | }; 32 | 33 | export interface IMappedForecastCurrent extends Omit { 34 | weather: IForecastWeather 35 | }; 36 | 37 | export interface IMappedForecastDay extends Omit { 38 | weather: IForecastWeather 39 | }; 40 | 41 | export interface IMappedForecastHour extends Omit { 42 | weather: IForecastWeather 43 | }; 44 | 45 | export interface IMappedForecast extends Omit { 46 | current: IMappedForecastCurrent; 47 | today: IMappedForecastDay; 48 | daily: IMappedForecastDay[]; 49 | hourly: IMappedForecastHour[]; 50 | }; 51 | 52 | export interface IFormatter { 53 | date(value: Date, format?: string): string; 54 | time(value: Date, format?: string): string; 55 | }; -------------------------------------------------------------------------------- /client/src/types/storage.ts: -------------------------------------------------------------------------------- 1 | import type LOCATION from '../enums/forecast/location'; 2 | import UNITS from '../enums/forecast/units'; 3 | import type MAP from '../enums/maps/map'; 4 | import type FORECAST_SECTION from '../enums/forecast/section'; 5 | 6 | import type { 7 | ILocation 8 | } from './location'; 9 | 10 | import type { 11 | IForecast 12 | } from './weather'; 13 | 14 | interface ISection { 15 | type: FORECAST_SECTION; 16 | visible: boolean; 17 | options: any; 18 | } 19 | 20 | export interface IForecastSettings { 21 | sections: ISection[]; 22 | } 23 | 24 | export interface IMapSettings { 25 | default: MAP; 26 | zoom: number; 27 | pitch: number; 28 | framerate: number; 29 | } 30 | 31 | export interface ISettings { 32 | version: number; 33 | units: UNITS; 34 | theme: string; 35 | location?: ILocation | LOCATION; 36 | locations?: ILocation[]; 37 | forecast: IForecastSettings; 38 | maps: IMapSettings; 39 | }; 40 | 41 | export interface IStoredData { 42 | lastUpdated: Date; 43 | location: ILocation; 44 | forecast: IForecast; 45 | } -------------------------------------------------------------------------------- /client/src/types/themes.ts: -------------------------------------------------------------------------------- 1 | export interface ITheme { 2 | id: string; 3 | name: string; 4 | colour: string | Record; 5 | class: string | string[]; 6 | mapStyle: string | Record; 7 | } -------------------------------------------------------------------------------- /client/src/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from 'vue' 3 | export default Vue 4 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | } 6 | } -------------------------------------------------------------------------------- /client/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | module.exports = env => require(`./build/${env}`); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/app", 3 | "repository": "https://github.com/andrewcourtice/ocula.git", 4 | "author": "Andrew Courtice", 5 | "description": "The free and open-source progressive weather app", 6 | "license": "MIT", 7 | "private": true, 8 | "workspaces": [ 9 | "api", 10 | "client", 11 | "packages/*" 12 | ], 13 | "scripts": { 14 | "dev": "cd client && yarn start --port $PORT", 15 | "build": "cd client && yarn build" 16 | }, 17 | "devDependencies": { 18 | "typescript": "^4.1.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/charts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/charts", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@ocula/utilities": "1.0.0", 8 | "d3-array": "^2.9.1", 9 | "d3-axis": "^2.0.0", 10 | "d3-ease": "^2.0.0", 11 | "d3-path": "^2.0.0", 12 | "d3-scale": "^3.2.3", 13 | "d3-selection": "^2.0.0", 14 | "d3-shape": "^2.0.0", 15 | "d3-transition": "^2.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/charts/src/charts/line/constants/curve.ts: -------------------------------------------------------------------------------- 1 | import LINE_TYPE from '../enums/line-type'; 2 | 3 | import * as d3 from '../../../d3'; 4 | 5 | export default { 6 | [LINE_TYPE.line]: d3.curveLinear, 7 | [LINE_TYPE.spline]: d3.curveCatmullRom.alpha(1), 8 | [LINE_TYPE.step]: d3.curveStep 9 | }; -------------------------------------------------------------------------------- /packages/charts/src/charts/line/enums/line-type.ts: -------------------------------------------------------------------------------- 1 | const enum LINE_TYPE { 2 | line = 'line', 3 | spline = 'spline', 4 | step = 'step' 5 | }; 6 | 7 | export default LINE_TYPE; -------------------------------------------------------------------------------- /packages/charts/src/charts/line/enums/marker-type.ts: -------------------------------------------------------------------------------- 1 | const enum MARKER_TYPE { 2 | point = 'point', 3 | arrow = 'arrow' 4 | }; 5 | 6 | export default MARKER_TYPE; -------------------------------------------------------------------------------- /packages/charts/src/charts/line/types/index.ts: -------------------------------------------------------------------------------- 1 | import LINE_TYPE from '../enums/line-type'; 2 | import MARKER_TYPE from '../enums/marker-type'; 3 | 4 | import SCALE from '../../../enums/scale'; 5 | 6 | import type { 7 | IChartOptions 8 | } from '../../_base/chart'; 9 | 10 | export interface ILinePoint { 11 | value: T; 12 | xValue: number; 13 | yValue: number; 14 | x: number; 15 | y0: number; 16 | y1: number; 17 | }; 18 | 19 | export interface ILineScaleOptions { 20 | type?: SCALE, 21 | value?(value: T, index?: number): any; 22 | format?(value: any): string; 23 | } 24 | 25 | export interface ILineOptions extends IChartOptions { 26 | type?: LINE_TYPE, 27 | scales: { 28 | x?: ILineScaleOptions; 29 | y?: ILineScaleOptions; 30 | }, 31 | markers?: { 32 | type?: MARKER_TYPE; 33 | visible?: boolean; 34 | }, 35 | labels?: { 36 | visible?: boolean; 37 | content?(value: ILinePoint, index?: number): any; 38 | }, 39 | classes?: { 40 | svg?: string; 41 | canvas?: string; 42 | lineGroup?: string; 43 | markerGroup?: string; 44 | line?: string; 45 | area?: string; 46 | marker?: string; 47 | }, 48 | colours?: { 49 | line?: string; 50 | marker?: string; 51 | axis?: string; 52 | tick?: string; 53 | label?: string; 54 | } 55 | } -------------------------------------------------------------------------------- /packages/charts/src/d3/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'd3-array'; 2 | export * from 'd3-axis'; 3 | export * from 'd3-ease'; 4 | export * from 'd3-path'; 5 | export * from 'd3-scale'; 6 | export * from 'd3-selection'; 7 | export * from 'd3-shape'; 8 | export * from 'd3-transition'; -------------------------------------------------------------------------------- /packages/charts/src/enums/scale.ts: -------------------------------------------------------------------------------- 1 | const enum SCALE { 2 | linear = 'linear', 3 | point = 'point', 4 | time = 'time' 5 | }; 6 | 7 | export default SCALE; -------------------------------------------------------------------------------- /packages/charts/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SCALE_TYPE } from './enums/scale'; 2 | 3 | export { default as LINE_TYPE } from './charts/line/enums/line-type'; 4 | export { default as MARKER_TYPE } from './charts/line/enums/marker-type'; 5 | export { default as LineChart } from './charts/line'; 6 | export * from './charts/line/types'; -------------------------------------------------------------------------------- /packages/charts/src/scales/index.ts: -------------------------------------------------------------------------------- 1 | import SCALE from '../enums/scale'; 2 | 3 | import linear from './linear'; 4 | import point from './point'; 5 | import time from './time'; 6 | 7 | export const SCALES = { 8 | [SCALE.linear]: linear, 9 | [SCALE.point]: point, 10 | [SCALE.time]: time 11 | }; 12 | 13 | export function getScale(data, options, range) { 14 | const { 15 | type 16 | } = options; 17 | 18 | return (SCALES[type] || SCALES[SCALE.linear])(data, options, range); 19 | } 20 | 21 | export default SCALES; -------------------------------------------------------------------------------- /packages/charts/src/scales/linear.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from '../d3'; 2 | 3 | export default function(data, options, range) { 4 | const { 5 | value 6 | } = options; 7 | 8 | const extent = d3.extent(data, value); 9 | const domain = [Math.min(extent[0], 0), Math.max(extent[1], 0)]; 10 | 11 | return d3.scaleLinear() 12 | .domain(domain) 13 | .range(range) 14 | .nice(); 15 | } -------------------------------------------------------------------------------- /packages/charts/src/scales/point.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from '../d3'; 2 | 3 | export default function(data, options, range) { 4 | const { 5 | value 6 | } = options; 7 | 8 | const domain = data.map(value); 9 | 10 | return d3.scalePoint() 11 | .domain(domain) 12 | .range(range); 13 | } -------------------------------------------------------------------------------- /packages/charts/src/scales/time.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from '../d3'; 2 | 3 | export default function(data, options, range) { 4 | const { 5 | value 6 | } = options; 7 | 8 | const domain = d3.extent(data, value); 9 | 10 | return d3.scaleTime() 11 | .domain(domain) 12 | .range(range); 13 | } -------------------------------------------------------------------------------- /packages/components/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/components", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@ocula/event-emitter": "1.0.0", 8 | "@ocula/style": "1.0.0", 9 | "@ocula/task-queue": "1.0.0", 10 | "@ocula/utilities": "1.0.0", 11 | "mapbox-gl": "^2.0.0" 12 | }, 13 | "peerDependencies": { 14 | "vue": "3.0.5" 15 | }, 16 | "devDependencies": { 17 | "@types/mapbox-gl": "^1.12.9" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/components/src/components/accordion/accordion-pane.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /packages/components/src/components/accordion/accordion.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /packages/components/src/components/accordion/constants/events.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | openPane: 'open-pane', 3 | closePane: 'close-pane', 4 | togglePane: 'toggle-pane', 5 | paneOpened: 'pane-opened', 6 | paneClosed: 'pane-closed', 7 | closeAll: 'close-all-panes', 8 | closeExcept: 'close-all-panes-except' 9 | } as const; -------------------------------------------------------------------------------- /packages/components/src/components/block/block.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | -------------------------------------------------------------------------------- /packages/components/src/components/container/container.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /packages/components/src/components/core/confirm-modal.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /packages/components/src/components/core/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /packages/components/src/components/icon-button/icon-button.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 50 | 51 | -------------------------------------------------------------------------------- /packages/components/src/components/icon-label/icon-label.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | 34 | -------------------------------------------------------------------------------- /packages/components/src/components/icon/icon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | 36 | -------------------------------------------------------------------------------- /packages/components/src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Accordion from './accordion/accordion.vue'; 2 | import AccordionPane from './accordion/accordion-pane.vue'; 3 | import Block from './block/block.vue'; 4 | import Container from './container/container.vue'; 5 | import Drawer from './drawer/drawer.vue'; 6 | import Icon from './icon/icon.vue'; 7 | import IconButton from './icon-button/icon-button.vue'; 8 | import IconLabel from './icon-label/icon-label.vue'; 9 | import MapboxMap from './mapbox/mapbox-map.vue'; 10 | import MapboxLegend from './mapbox/mapbox-legend.vue'; 11 | import MapboxRasterLayer from './mapbox/mapbox-raster-layer.vue'; 12 | import Layout from './layout/layout.vue'; 13 | import Loader from './loader/loader.vue'; 14 | import Modal from './modal/modal.vue'; 15 | import SearchBox from './search-box/search-box.vue'; 16 | import TransitionBoxResize from './transitions/box-resize.vue'; 17 | 18 | import CoreComponents from './core/index.vue'; 19 | 20 | export default { 21 | Accordion, 22 | AccordionPane, 23 | Block, 24 | Container, 25 | Drawer, 26 | Icon, 27 | IconButton, 28 | IconLabel, 29 | MapboxMap, 30 | MapboxLegend, 31 | MapboxRasterLayer, 32 | Layout, 33 | Loader, 34 | Modal, 35 | SearchBox, 36 | TransitionBoxResize, 37 | CoreComponents 38 | }; -------------------------------------------------------------------------------- /packages/components/src/components/layout/layout.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 54 | 55 | -------------------------------------------------------------------------------- /packages/components/src/components/loader/loader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | 35 | -------------------------------------------------------------------------------- /packages/components/src/components/mapbox/compositions/layer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | inject, 3 | watch, 4 | onMounted, 5 | onBeforeUnmount, 6 | PropType 7 | } from 'vue'; 8 | 9 | import { 10 | stringUniqueId 11 | } from '@ocula/utilities'; 12 | 13 | import type { 14 | IInteractiveMap 15 | } from '../types'; 16 | 17 | import type { 18 | Layer, 19 | Layout, 20 | AnyPaint 21 | } from 'mapbox-gl'; 22 | 23 | export const layerProps = { 24 | 25 | id: { 26 | type: String, 27 | default: () => stringUniqueId() 28 | }, 29 | 30 | minzoom: { 31 | type: Number, 32 | default: 0 33 | }, 34 | 35 | maxzoom: { 36 | type: Number, 37 | default: 22 38 | }, 39 | 40 | layout: { 41 | type: Object as PropType 42 | }, 43 | 44 | paint: { 45 | type: Object as PropType 46 | } 47 | 48 | }; 49 | 50 | export function useLayer(props: any, layer: Layer) { 51 | const map = inject('map'); 52 | 53 | watch(() => props.layout, value => map.updateLayout(layer.id, value)); 54 | watch(() => props.paint, value => map.updatePaint(layer.id, value)); 55 | 56 | onMounted(() => map.addLayer(layer)); 57 | onBeforeUnmount(() => map.removeLayer(layer)); 58 | 59 | return { 60 | map 61 | }; 62 | } -------------------------------------------------------------------------------- /packages/components/src/components/mapbox/mapbox-legend.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 46 | 47 | -------------------------------------------------------------------------------- /packages/components/src/components/mapbox/mapbox-raster-layer.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/components/src/components/mapbox/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnyPaint, 3 | Layer, 4 | Layout 5 | } from 'mapbox-gl'; 6 | 7 | export interface IInteractiveMap { 8 | addLayer(layer: Layer): void; 9 | removeLayer(layer: Layer): void; 10 | updateLayout(layerId: string, layout: Layout): void; 11 | updatePaint(layerId: string, paint: AnyPaint): void; 12 | } -------------------------------------------------------------------------------- /packages/components/src/components/modal/modal.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 49 | 50 | -------------------------------------------------------------------------------- /packages/components/src/components/search-box/search-box.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 49 | 50 | -------------------------------------------------------------------------------- /packages/components/src/compositions/layer.ts: -------------------------------------------------------------------------------- 1 | import eventEmitter from '../event-emitter'; 2 | 3 | import { 4 | ref, 5 | onBeforeMount, 6 | onUnmounted 7 | } from 'vue'; 8 | 9 | import type { 10 | IPromisePayload 11 | } from '../types'; 12 | 13 | import type { 14 | IListener 15 | } from '@ocula/event-emitter'; 16 | 17 | import type { 18 | SetupContext 19 | } from '@vue/runtime-core'; 20 | 21 | export default function(id: string, { emit }: SetupContext) { 22 | const isOpen = ref(false); 23 | 24 | let promise: IPromisePayload; 25 | 26 | function open(payload: any, promisePayload: IPromisePayload) { 27 | emit('open', payload); 28 | 29 | promise = promisePayload; 30 | isOpen.value = true; 31 | } 32 | 33 | function close(payload: any) { 34 | emit('close', payload); 35 | 36 | if (promise) { 37 | promise.resolve(payload); 38 | promise = null; 39 | } 40 | 41 | isOpen.value = false; 42 | } 43 | 44 | function cancel(payload: any) { 45 | emit('cancel', payload); 46 | 47 | if (promise) { 48 | promise.reject(payload); 49 | promise = null; 50 | } 51 | 52 | isOpen.value = false; 53 | } 54 | 55 | let listeners = [] as IListener[]; 56 | 57 | onBeforeMount(() => { 58 | listeners = [ 59 | eventEmitter.on(`open:${id}`, open), 60 | eventEmitter.on(`close:${id}`, close), 61 | ]; 62 | }); 63 | 64 | onUnmounted(() => { 65 | listeners.forEach(({ dispose }) => dispose()); 66 | 67 | if (promise) { 68 | promise.reject(); 69 | } 70 | }); 71 | 72 | return { 73 | isOpen, 74 | open, 75 | close, 76 | cancel 77 | }; 78 | } -------------------------------------------------------------------------------- /packages/components/src/compositions/subscriber.ts: -------------------------------------------------------------------------------- 1 | import eventEmitter from '@ocula/event-emitter'; 2 | 3 | import { 4 | onBeforeMount, 5 | onUnmounted 6 | } from 'vue'; 7 | 8 | export default function(event: string, callback: Function): void { 9 | onBeforeMount(() => eventEmitter.on(event, callback)); 10 | onUnmounted(() => eventEmitter.off(event, callback)); 11 | } -------------------------------------------------------------------------------- /packages/components/src/compositions/timer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | onBeforeMount, 3 | onUnmounted 4 | } from 'vue'; 5 | 6 | type Timer = 'interval' | 'timeout'; 7 | 8 | interface ITimerApplication { 9 | set(handler: Function, timeout: number, ...args: any[]): number; 10 | clear(handle: number): void; 11 | } 12 | 13 | const TIMER = { 14 | interval: { 15 | set: (handler: Function, timeout: number, immediateInvoke: boolean = true) => { 16 | if (immediateInvoke) { 17 | handler(); 18 | } 19 | 20 | return window.setInterval(handler, timeout); 21 | }, 22 | clear: window.clearInterval 23 | }, 24 | timeout: { 25 | set: window.setTimeout, 26 | clear: window.clearTimeout 27 | } 28 | } as Record 29 | 30 | export default function useTimer(handler: Function, timeout: number, timer: Timer = 'interval'): number { 31 | let handle; 32 | 33 | const timerApplication = TIMER[timer]; 34 | 35 | onBeforeMount(() => handle = timerApplication.set.call(window, handler, timeout)); 36 | onUnmounted(() => timerApplication.clear.call(window, handle)); 37 | 38 | return handle 39 | } -------------------------------------------------------------------------------- /packages/components/src/constants/modals.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | confirm: 'modals:confirm' 3 | } as const; -------------------------------------------------------------------------------- /packages/components/src/controllers/components.ts: -------------------------------------------------------------------------------- 1 | import MODALS from '../constants/modals'; 2 | 3 | import eventEmitter from '../event-emitter'; 4 | 5 | import type { 6 | IConfirmModalPayload 7 | } from '../types'; 8 | 9 | class ComponentsController { 10 | 11 | public async open(id: string, payload?: any): Promise { 12 | return new Promise((resolve, reject) => { 13 | eventEmitter.emit(`open:${id}`, payload, { resolve, reject }); 14 | }); 15 | } 16 | 17 | public close(id: string, payload?: any): void { 18 | eventEmitter.emit(`close:${id}`, payload); 19 | } 20 | 21 | public async confirm(payload: IConfirmModalPayload): Promise { 22 | return this.open(MODALS.confirm, payload); 23 | } 24 | 25 | } 26 | 27 | export default new ComponentsController(); -------------------------------------------------------------------------------- /packages/components/src/directives/focus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive 3 | } from 'vue'; 4 | 5 | export default { 6 | 7 | mounted(el, binding) { 8 | const { 9 | value, 10 | modifiers 11 | } = binding; 12 | 13 | const query = value || 'input, select, textarea'; 14 | const element = el.querySelector(query) || el; 15 | 16 | if (!element) { 17 | return; 18 | } 19 | 20 | element.focus(); 21 | 22 | if (modifiers.highlight) { 23 | element.setSelectionRange(0, element.value.length); 24 | } 25 | } 26 | 27 | } as Directive; -------------------------------------------------------------------------------- /packages/components/src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import focus from './focus'; 2 | import meta from './meta'; 3 | import tooltip from './tooltip'; 4 | import visible from './visible'; 5 | 6 | export default { 7 | focus, 8 | meta, 9 | tooltip, 10 | visible 11 | }; -------------------------------------------------------------------------------- /packages/components/src/directives/meta.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive 3 | } from 'vue'; 4 | 5 | import { 6 | domSetMeta 7 | } from '@ocula/utilities'; 8 | 9 | export default { 10 | 11 | mounted(element, { arg, value }) { 12 | domSetMeta(arg, value); 13 | }, 14 | 15 | updated(element, { arg, value }) { 16 | domSetMeta(arg, value); 17 | }, 18 | 19 | unmounted(element, { arg }) { 20 | domSetMeta(arg); 21 | } 22 | 23 | } as Directive; -------------------------------------------------------------------------------- /packages/components/src/directives/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive 3 | } from 'vue'; 4 | 5 | function upsert(element: HTMLElement, binding) { 6 | const { 7 | value, 8 | arg = 'top' 9 | } = binding; 10 | 11 | element.setAttribute('data-tooltip', value); 12 | element.setAttribute('data-tooltip-position', arg); 13 | } 14 | 15 | export default { 16 | 17 | mounted: upsert, 18 | updated: upsert, 19 | 20 | unmounted(element) { 21 | element.removeAttribute('data-tooltip'); 22 | element.removeAttribute('data-tooltip-position'); 23 | } 24 | 25 | } as Directive; -------------------------------------------------------------------------------- /packages/components/src/directives/visible.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Directive, 3 | DirectiveBinding 4 | } from '@vue/runtime-core'; 5 | 6 | const VISIBILITY_MAP = { 7 | 0: 'hidden', 8 | 1: 'visible' 9 | }; 10 | 11 | function updateVisibility(element: HTMLElement, binding: DirectiveBinding) { 12 | element.style.visibility = VISIBILITY_MAP[+!!binding.value]; 13 | } 14 | 15 | export default { 16 | 17 | mounted: updateVisibility, 18 | updated: updateVisibility, 19 | 20 | unmounted(element) { 21 | element.style.visibility = null; 22 | } 23 | 24 | } as Directive; -------------------------------------------------------------------------------- /packages/components/src/event-emitter/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventEmitter 3 | } from '@ocula/event-emitter'; 4 | 5 | export default new EventEmitter(); -------------------------------------------------------------------------------- /packages/components/src/helpers/get-listeners.ts: -------------------------------------------------------------------------------- 1 | import { 2 | typeIsFunction 3 | } from '@ocula/utilities'; 4 | 5 | export default function getListeners(attrs: Record): Record { 6 | const listeners = {}; 7 | 8 | for (const key in attrs) { 9 | const value = attrs[key]; 10 | 11 | if (key.startsWith('on') && typeIsFunction(value)) { 12 | listeners[key.replace(/^on/, '').toLowerCase()] = value; 13 | } 14 | } 15 | 16 | return listeners; 17 | } -------------------------------------------------------------------------------- /packages/components/src/index.ts: -------------------------------------------------------------------------------- 1 | import '@ocula/style/src/index.scss'; 2 | 3 | import directives from './directives'; 4 | import components from './components'; 5 | 6 | import type { 7 | App 8 | } from 'vue'; 9 | 10 | type Registrar = 'directive' | 'component'; 11 | 12 | function register(application: App, registrar: Registrar, dictionary: Record): void { 13 | Object.keys(dictionary).forEach(key => application[registrar].call(application, key, dictionary[key])); 14 | } 15 | 16 | // Compositions 17 | export { default as useSubscriber } from './compositions/subscriber'; 18 | export { default as useTimer } from './compositions/timer'; 19 | export { default as componentsController } from './controllers/components'; 20 | 21 | // Helpers 22 | export { default as getListeners } from './helpers/get-listeners'; 23 | 24 | export default { 25 | 26 | install(application: App) { 27 | register(application, 'directive', directives); 28 | register(application, 'component', components); 29 | } 30 | 31 | }; -------------------------------------------------------------------------------- /packages/components/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface IPromisePayload { 2 | resolve(value?: any): void; 3 | reject(value?: any): void; 4 | }; 5 | 6 | export interface IConfirmModalPayload { 7 | message: string; 8 | confirmLabel?: string; 9 | cancelLabel?: string; 10 | }; -------------------------------------------------------------------------------- /packages/event-emitter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/event-emitter", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /packages/event-emitter/src/index.ts: -------------------------------------------------------------------------------- 1 | export interface IListener { 2 | dispose(): void 3 | } 4 | 5 | export class EventEmitter { 6 | 7 | private listeners: { 8 | [key: string]: Function[] 9 | }; 10 | 11 | constructor() { 12 | this.listeners = {}; 13 | } 14 | 15 | on(event: string, handler: Function): IListener { 16 | if (!this.listeners[event]) { 17 | this.listeners[event] = []; 18 | } 19 | 20 | this.listeners[event].push(handler); 21 | 22 | return { 23 | dispose: () => this.off(event, handler) 24 | }; 25 | } 26 | 27 | off(event: string, handler: Function): void { 28 | const listeners = this.listeners[event]; 29 | 30 | if (!listeners) { 31 | return; 32 | } 33 | 34 | this.listeners[event] = listeners.filter(listener => listener !== handler); 35 | 36 | if (this.listeners[event].length === 0) { 37 | delete this.listeners[event]; 38 | } 39 | } 40 | 41 | once(event: string, handler: Function): IListener { 42 | const callback = (...args) => { 43 | handler(...args); 44 | this.off(event, callback); 45 | }; 46 | 47 | return this.on(event, callback); 48 | } 49 | 50 | emit(event: string, ...args) { 51 | const handlers = this.listeners[event]; 52 | 53 | if (!handlers) { 54 | return; 55 | } 56 | 57 | handlers.forEach(handler => handler(...args)); 58 | } 59 | 60 | } 61 | 62 | export default new EventEmitter(); -------------------------------------------------------------------------------- /packages/router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/router", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts", 5 | "dependencies": { 6 | "vue-router": "4.0.1" 7 | }, 8 | "peerDependencies": { 9 | "vue": "3.0.5" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/router/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRouter, 3 | createWebHistory 4 | } from 'vue-router'; 5 | 6 | import type { 7 | App 8 | } from 'vue'; 9 | 10 | import type { 11 | Router, 12 | RouteRecordRaw 13 | } from 'vue-router'; 14 | 15 | export type { 16 | RouteRecord, 17 | RouteRecordRaw 18 | } from 'vue-router'; 19 | 20 | export let router: Router; 21 | 22 | export default { 23 | 24 | install(app: App, routes: RouteRecordRaw[]) { 25 | router = createRouter({ 26 | history: createWebHistory(), 27 | routes 28 | }); 29 | 30 | app.use(router); 31 | } 32 | 33 | }; -------------------------------------------------------------------------------- /packages/state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/state", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts", 5 | "license": "MIT", 6 | "peerDependencies": { 7 | "vue": "3.0.5" 8 | }, 9 | "dependencies": { 10 | "@ocula/utilities": "1.0.0", 11 | "@vue/devtools-api": "^6.0.0-beta.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/state/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ComputedRef 3 | } from '@vue/reactivity'; 4 | 5 | export type Getter = (state: T) => U; 6 | export type Mutation = (state: T) => void; 7 | 8 | export interface IStore { 9 | state: T; 10 | getter(getter: Getter): ComputedRef; 11 | mutate(name:string, mutation: Mutation): void; 12 | destroy(): void; 13 | }; -------------------------------------------------------------------------------- /packages/state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "DOM", 6 | "ESNext" 7 | ] 8 | } 9 | } -------------------------------------------------------------------------------- /packages/style/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/style", 3 | "version": "1.0.0", 4 | "main": "./src/index.scss", 5 | "license": "MIT", 6 | "dependencies": { 7 | "flex-layout-attribute": "^1.0.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/style/src/_base.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | overflow: auto; 8 | } 9 | 10 | html { 11 | font-size: 16px; 12 | box-sizing: border-box; 13 | -moz-box-sizing: border-box; 14 | } 15 | 16 | *, 17 | *:before, 18 | *:after { 19 | box-sizing: inherit; 20 | } 21 | 22 | body { 23 | font-family: var(--font__family); 24 | font-size: var(--font__size); 25 | font-weight: var(--font__weight); 26 | line-height: 1.5; 27 | color: var(--font__colour); 28 | background-color: var(--background__colour); 29 | -webkit-font-smoothing: antialiased; 30 | text-rendering: optimizeLegibility; 31 | -moz-osx-font-smoothing: grayscale; 32 | } 33 | 34 | @media (hover: hover) { 35 | 36 | ::-webkit-scrollbar { 37 | width: 16px; 38 | height: 16px; 39 | } 40 | 41 | ::-webkit-scrollbar-track { 42 | background-color: var(--background__colour); 43 | } 44 | 45 | ::-webkit-scrollbar-thumb { 46 | width: 8px; 47 | height: 8px; 48 | min-height: 32px; 49 | background-color: rgba(#CCCCCC, 0.75); 50 | background-clip: padding-box; 51 | border: 4px solid transparent; 52 | border-radius: 8px; 53 | 54 | &:hover { 55 | background-color: #CCCCCC; 56 | } 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /packages/style/src/_buttons.scss: -------------------------------------------------------------------------------- 1 | button, 2 | .button { 3 | display: inline-block; 4 | padding: var(--spacing__x-small) var(--spacing__medium); 5 | font: inherit; 6 | font-weight: var(--font__weight--heavy); 7 | border: none; 8 | border-radius: var(--border__radius); 9 | background-color: #EEEEEE; 10 | cursor: pointer; 11 | transition: background var(--transition__timing) var(--transition__easing--default); 12 | 13 | &:hover, 14 | &:focus { 15 | background-color: darken(#EEEEEE, 20%); 16 | } 17 | } 18 | 19 | .button--primary { 20 | color: var(--font__colour--compliment); 21 | background-color: var(--colour__primary); 22 | 23 | &:hover, 24 | &:focus { 25 | background-color: var(--colour__primary--dark); 26 | } 27 | } 28 | 29 | .button--ghost { 30 | font-weight: var(--font__weight); 31 | background: none; 32 | 33 | &:hover, 34 | &:focus { 35 | background: none; 36 | } 37 | 38 | &:focus { 39 | outline: none; 40 | } 41 | } -------------------------------------------------------------------------------- /packages/style/src/_dots.scss: -------------------------------------------------------------------------------- 1 | .dot { 2 | display: inline-block; 3 | width: 0.5em; 4 | height: 0.5em; 5 | border-radius: 50%; 6 | vertical-align: middle; 7 | background-color: var(--border__colour); 8 | } 9 | 10 | .dot--large { 11 | width: 1em; 12 | height: 1em; 13 | } -------------------------------------------------------------------------------- /packages/style/src/_grid.scss: -------------------------------------------------------------------------------- 1 | $min-size: 1; 2 | $max-size: 12; 3 | 4 | $alignments: ( 5 | auto, 6 | start, 7 | end, 8 | center, 9 | stretch 10 | ); 11 | 12 | [grid] { 13 | display: grid; 14 | grid-gap: var(--spacing__small); 15 | } 16 | 17 | @for $size from $min-size to $max-size { 18 | 19 | [grid^="#{$size}"] { 20 | grid-template-columns: repeat($size, 1fr); 21 | } 22 | 23 | } 24 | 25 | @each $alignment in $alignments { 26 | 27 | [grid*="#{$alignment}-"] { 28 | justify-items: $alignment; 29 | } 30 | 31 | [grid*="-#{$alignment}"] { 32 | align-items: $alignment; 33 | } 34 | 35 | [grid-self^="#{$alignment}"] { 36 | justify-self: $alignment; 37 | } 38 | 39 | [grid-self*="-#{$alignment}"] { 40 | align-self: $alignment; 41 | } 42 | 43 | } 44 | 45 | [grid-gap="none"] { 46 | grid-gap: 0; 47 | } 48 | 49 | @each $spacing-name, $spacing-value in $spacings { 50 | 51 | [grid-gap="#{$spacing-name}"] { 52 | grid-gap: var(--spacing__#{ $spacing-name }); 53 | } 54 | 55 | [grid-gap|="#{$spacing-name}"] { 56 | grid-column-gap: var(--spacing__#{ $spacing-name }); 57 | } 58 | 59 | [grid-gap$="-#{$spacing-name}"] { 60 | grid-row-gap: var(--spacing__#{ $spacing-name }); 61 | } 62 | 63 | } 64 | 65 | @each $breakpoint-name, $breakpoint-value in $breakpoints { 66 | 67 | @include breakpoint($breakpoint-name) { 68 | 69 | @for $size from $min-size to $max-size { 70 | 71 | [grid*="#{$breakpoint-name}-#{$size}"] { 72 | grid-template-columns: repeat($size, 1fr); 73 | } 74 | 75 | } 76 | 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /packages/style/src/_menus.scss: -------------------------------------------------------------------------------- 1 | .menu { 2 | display: block; 3 | } 4 | 5 | .menu-item { 6 | padding: var(--spacing__small); 7 | border-radius: var(--border__radius); 8 | cursor: pointer; 9 | } 10 | 11 | .menu-item--active { 12 | color: var(--colour__primary); 13 | } 14 | 15 | @media (hover: hover) { 16 | 17 | .menu-item { 18 | 19 | &:hover { 20 | background-color: var(--background__colour--hover); 21 | } 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /packages/style/src/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import "./_variables.scss"; 2 | 3 | @mixin text-truncate { 4 | overflow: hidden; 5 | text-overflow: ellipsis; 6 | white-space: nowrap; 7 | } 8 | 9 | @mixin breakpoint($breakpoint) { 10 | 11 | $size: map-get($breakpoints, $breakpoint); 12 | 13 | @media screen and (min-width: #{ $size }) { 14 | @content; 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /packages/style/src/_spacing.scss: -------------------------------------------------------------------------------- 1 | $directions: ( 2 | top: (top), 3 | right: (right), 4 | bottom: (bottom), 5 | left: (left), 6 | vertical: (top, bottom), 7 | horizontal: (left, right), 8 | all: (top, right, bottom, left) 9 | ); 10 | 11 | $spacing-properties: ( 12 | margin, 13 | padding 14 | ); 15 | 16 | @each $property-name in $spacing-properties { 17 | 18 | .#{ $property-name }__all--none { 19 | #{ $property-name }-top: 0; 20 | #{ $property-name }-right: 0; 21 | #{ $property-name }-bottom: 0; 22 | #{ $property-name }-left: 0; 23 | } 24 | 25 | @each $direction-name, $direction-value in $directions { 26 | 27 | .#{ $property-name }__#{ $direction-name }--none { 28 | 29 | @each $direction-property in $direction-value { 30 | #{ $property-name }-#{ $direction-property }: 0; 31 | } 32 | 33 | } 34 | 35 | @each $spacing-name, $spacing-value in $spacings { 36 | 37 | .#{ $property-name }__#{ $direction-name }--#{ $spacing-name } { 38 | 39 | @each $direction-property in $direction-value { 40 | #{ $property-name }-#{ $direction-property }: var(--spacing__#{ $spacing-name }); 41 | } 42 | 43 | } 44 | 45 | } 46 | 47 | } 48 | } -------------------------------------------------------------------------------- /packages/style/src/_tables.scss: -------------------------------------------------------------------------------- 1 | table { 2 | border-collapse: collapse; 3 | } 4 | 5 | th, 6 | td { 7 | padding: var(--spacing__x-small); 8 | } 9 | 10 | .table--fixed { 11 | width: 100%; 12 | max-width: 100%; 13 | table-layout: fixed; 14 | } -------------------------------------------------------------------------------- /packages/style/src/_tooltips.scss: -------------------------------------------------------------------------------- 1 | @media (hover: hover) { 2 | 3 | [data-tooltip] { 4 | position: relative; 5 | 6 | &::after { 7 | display: block; 8 | position: absolute; 9 | content: attr(data-tooltip); 10 | width: auto; 11 | padding: var(--spacing__xx-small) var(--spacing__x-small); 12 | font-size: var(--font__size--small); 13 | color: var(--tooltip__font-colour); 14 | white-space: nowrap; 15 | background: var(--tooltip__background); 16 | border-radius: 3px; 17 | overflow: hidden; 18 | opacity: 0; 19 | transition: opacity var(--transition__timing--long) var(--transition__easing--default) 500ms; 20 | } 21 | 22 | &:hover { 23 | 24 | &::after { 25 | opacity: 1; 26 | } 27 | } 28 | } 29 | 30 | [data-tooltip-position="top"], 31 | [data-tooltip-position="bottom"] { 32 | 33 | &::after { 34 | left: 50%; 35 | } 36 | } 37 | 38 | [data-tooltip-position="left"], 39 | [data-tooltip-position="right"] { 40 | 41 | &::after { 42 | top: 50%; 43 | } 44 | } 45 | 46 | [data-tooltip-position="top"] { 47 | 48 | &::after { 49 | bottom: 100%; 50 | transform: translate(-50%, -0.5rem); 51 | } 52 | } 53 | 54 | [data-tooltip-position="bottom"] { 55 | 56 | &::after { 57 | top: 100%; 58 | transform: translate(-50%, 0.5rem); 59 | } 60 | } 61 | 62 | [data-tooltip-position="left"] { 63 | 64 | &::after { 65 | right: 100%; 66 | transform: translate(-0.5rem, -50%); 67 | } 68 | } 69 | 70 | [data-tooltip-position="right"] { 71 | 72 | &::after { 73 | left: 100%; 74 | transform: translate(0.5rem, -50%); 75 | } 76 | } 77 | 78 | } 79 | 80 | @keyframes tooltip { 81 | 82 | from { 83 | opacity: 0; 84 | } 85 | 86 | to { 87 | opacity: 1; 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /packages/style/src/_typography.scss: -------------------------------------------------------------------------------- 1 | h1, 2 | h2, 3 | h3, 4 | h4, 5 | h5, 6 | h6 { 7 | margin: 0; 8 | font-weight: var(--font__weight--heavy); 9 | } 10 | 11 | a, 12 | .link { 13 | text-decoration: none; 14 | } 15 | 16 | a { 17 | color: var(--colour__primary); 18 | 19 | &:hover { 20 | color: var(--colour__primary--dark); 21 | } 22 | } 23 | 24 | .link { 25 | color: inherit; 26 | } 27 | 28 | .link--inherit { 29 | color: inherit; 30 | 31 | &:hover { 32 | color: inherit; 33 | } 34 | } 35 | 36 | .text--meta { 37 | color: var(--font__colour--meta); 38 | } 39 | 40 | .text--medium { 41 | font-weight: var(--font__weight--medium); 42 | } 43 | 44 | strong, 45 | .text--heavy { 46 | font-weight: var(--font__weight--heavy); 47 | } 48 | 49 | small, 50 | .text--small { 51 | font-size: var(--font__size--small); 52 | } 53 | 54 | .text--x-small { 55 | font-size: var(--font__size--x-small); 56 | } 57 | 58 | .text--left { 59 | text-align: left; 60 | } 61 | 62 | .text--centre { 63 | text-align: center; 64 | } 65 | 66 | .text--right { 67 | text-align: right; 68 | } 69 | 70 | .text--tight { 71 | line-height: 1; 72 | } 73 | 74 | .text--no-wrap { 75 | overflow: hidden; 76 | white-space: nowrap; 77 | } 78 | 79 | .text--truncate { 80 | @include text-truncate; 81 | } 82 | 83 | @media (hover: hover) { 84 | 85 | a { 86 | 87 | &:hover { 88 | color: var(--colour__primary--dark); 89 | } 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /packages/style/src/_variables.scss: -------------------------------------------------------------------------------- 1 | $spacings: ( 2 | xx-small: 0.25rem, 3 | x-small: 0.5rem, 4 | small: 1rem, 5 | medium: 1.5rem, 6 | large: 2rem, 7 | x-large: 3rem, 8 | xx-large: 4rem 9 | ); 10 | 11 | $breakpoints: ( 12 | lg: 64em, 13 | md: 52em, 14 | sm: 40em 15 | ); 16 | 17 | $colour__primary: #5D9BE5; 18 | $colour__primary--light: lighten(#5D9BE5, 20%); 19 | $colour__primary--dark: darken(#5D9BE5, 20%); 20 | 21 | :root { 22 | --font__size: 14px; 23 | --font__size--small: 0.875em; 24 | --font__size--x-small: 0.75em; 25 | --font__size--large: 1.25em; 26 | --font__size--x-large: 1.5em; 27 | 28 | --font__weight: 400; 29 | --font__weight--medium: 500; 30 | --font__weight--heavy: 700; 31 | 32 | --font__family: 'DM Sans', sans-serif; 33 | 34 | --font__colour: #353539; 35 | --font__colour--compliment: #F7F7F7; 36 | --font__colour--meta: #9999AA; 37 | 38 | --transition__timing: 250ms; 39 | --transition__timing--long: 400ms; 40 | --transition__timing--fade: 1s; 41 | --transition__easing--default: cubic-bezier(0.165, 0.84, 0.44, 1); // quartic-out 42 | 43 | --colour__primary: #5D9BE5; 44 | --colour__primary--light: #{ lighten(#5D9BE5, 20%) }; 45 | --colour__primary--dark: #{ darken(#5D9BE5, 20%) }; 46 | 47 | --background__colour: #FFFFFF; 48 | --background__colour--hover: #EEEEEE; 49 | 50 | --border__colour: #EDEDED; 51 | --border__radius: 5px; 52 | --border__radius--large: 1rem; 53 | 54 | --tooltip__background: #333333; 55 | --tooltip__font-colour: #FFFFFF; 56 | 57 | @each $name, $value in $spacings { 58 | --spacing__#{ $name }: #{ $value }; 59 | } 60 | } -------------------------------------------------------------------------------- /packages/style/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "~flex-layout-attribute/sass/flex-layout-attribute.scss"; 2 | 3 | @import "./_variables.scss"; 4 | @import "./_mixins.scss"; 5 | @import "./_base.scss"; 6 | @import "./_spacing.scss"; 7 | @import "./_grid.scss"; 8 | @import "./_typography.scss"; 9 | @import "./_buttons.scss"; 10 | @import "./_inputs.scss"; 11 | @import "./_tables.scss"; 12 | @import "./_menus.scss"; 13 | @import "./_dots.scss"; 14 | @import "./_tooltips.scss"; -------------------------------------------------------------------------------- /packages/task-queue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/task-queue", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /packages/task-queue/src/index.ts: -------------------------------------------------------------------------------- 1 | export class TaskQueue { 2 | 3 | private _tasks: Set; 4 | private _suppressErrors: boolean; 5 | 6 | constructor(suppressErrors: boolean = false) { 7 | this._tasks = new Set(); 8 | this._suppressErrors = suppressErrors; 9 | } 10 | 11 | get suppressErrors() { 12 | return this._suppressErrors; 13 | } 14 | 15 | set suppressErrors(value) { 16 | this._suppressErrors = !!value; 17 | } 18 | 19 | add(task: Function): void { 20 | this._tasks.add(task); 21 | } 22 | 23 | remove(task: Function): void { 24 | this._tasks.delete(task); 25 | } 26 | 27 | clear(): void { 28 | this._tasks.clear(); 29 | } 30 | 31 | run(...args: any[]): void { 32 | this._tasks.forEach(task => { 33 | try { 34 | task(...args); 35 | } catch (error) { 36 | if (!this._suppressErrors) { 37 | throw error; 38 | } 39 | } finally { 40 | this.remove(task); 41 | } 42 | }); 43 | } 44 | 45 | } 46 | 47 | export default new TaskQueue(); -------------------------------------------------------------------------------- /packages/utilities/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ocula/utilities", 3 | "version": "1.0.0", 4 | "main": "./src/index.ts", 5 | "license": "MIT", 6 | "dependencies": { 7 | "date-fns": "^2.16.1", 8 | "date-fns-tz": "^1.0.12", 9 | "lodash": "^4.17.20", 10 | "nanoid": "^3.1.20" 11 | }, 12 | "devDependencies": { 13 | "@types/lodash": "^4.14.165" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/utilities/src/array/join-by.ts: -------------------------------------------------------------------------------- 1 | import getAccessor from '../value/get-accessor'; 2 | 3 | type Iteratee = (value: T) => string; 4 | 5 | export default function joinBy(array: T[], iteratee: Iteratee = value => String(value), separator: string = ','): string { 6 | const accessor = getAccessor(iteratee); 7 | const trimmer = new RegExp(`${separator}$`); 8 | 9 | return array.reduce((output, value) => output + accessor(value) + separator, '') 10 | .replace(trimmer, ''); 11 | } -------------------------------------------------------------------------------- /packages/utilities/src/array/order-by.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/orderBy'; -------------------------------------------------------------------------------- /packages/utilities/src/array/swap-by.ts: -------------------------------------------------------------------------------- 1 | import isNumber from '../type/is-number'; 2 | import clamp from '../number/clamp'; 3 | 4 | 5 | type Predicate = (value: T) => boolean; 6 | 7 | function getIndex(array: T[], predicate: number | Predicate): number { 8 | const index = isNumber(predicate) ? predicate : array.findIndex(predicate); 9 | 10 | return clamp(index, 0, array.length - 1); 11 | } 12 | 13 | export default function swapBy(array: T[], predicateA: number | Predicate, predicateB: number | Predicate): T[] { 14 | const clone = array.slice(); 15 | 16 | const indexA = getIndex(array, predicateA); 17 | const indexB = getIndex(array, predicateB); 18 | 19 | clone[indexA] = array[indexB]; 20 | clone[indexB] = array[indexA]; 21 | 22 | return clone; 23 | } -------------------------------------------------------------------------------- /packages/utilities/src/array/union-with.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/unionWith'; -------------------------------------------------------------------------------- /packages/utilities/src/array/unique-by.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/uniqBy'; -------------------------------------------------------------------------------- /packages/utilities/src/date/format-distance-to-now.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns/formatDistanceToNow'; -------------------------------------------------------------------------------- /packages/utilities/src/date/format-distance.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns/formatDistance'; -------------------------------------------------------------------------------- /packages/utilities/src/date/format.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns-tz/format'; -------------------------------------------------------------------------------- /packages/utilities/src/date/from-unix.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns/fromUnixTime'; -------------------------------------------------------------------------------- /packages/utilities/src/date/is-today.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns/isToday'; -------------------------------------------------------------------------------- /packages/utilities/src/date/to-unix.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns/getUnixTime'; -------------------------------------------------------------------------------- /packages/utilities/src/date/utc-to-zoned.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'date-fns-tz/utcToZonedTime'; -------------------------------------------------------------------------------- /packages/utilities/src/dom/set-meta.ts: -------------------------------------------------------------------------------- 1 | export default function setMeta(key: string, value: string = ''): void { 2 | let meta = document.querySelector(`meta[name=${key}]`); 3 | 4 | if (!meta) { 5 | meta = document.createElement('meta'); 6 | meta.setAttribute('name', key); 7 | document.head.appendChild(meta); 8 | } 9 | 10 | meta.setAttribute('content', value); 11 | } -------------------------------------------------------------------------------- /packages/utilities/src/env/_base/is-env.ts: -------------------------------------------------------------------------------- 1 | type Environment = 'development' | 'production'; 2 | 3 | export default function isEnv(environment: Environment): boolean { 4 | return process.env.NODE_ENV === environment; 5 | } -------------------------------------------------------------------------------- /packages/utilities/src/env/is-development.ts: -------------------------------------------------------------------------------- 1 | import isEnv from './_base/is-env'; 2 | 3 | export default isEnv('development'); -------------------------------------------------------------------------------- /packages/utilities/src/env/is-production.ts: -------------------------------------------------------------------------------- 1 | import isEnv from './_base/is-env'; 2 | 3 | export default isEnv('production'); -------------------------------------------------------------------------------- /packages/utilities/src/function/debounce.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/debounce'; -------------------------------------------------------------------------------- /packages/utilities/src/function/identity.ts: -------------------------------------------------------------------------------- 1 | export default function identity(value) { 2 | return value; 3 | } -------------------------------------------------------------------------------- /packages/utilities/src/function/noop.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/noop'; -------------------------------------------------------------------------------- /packages/utilities/src/number/clamp.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/clamp'; -------------------------------------------------------------------------------- /packages/utilities/src/number/max-by.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/maxBy'; -------------------------------------------------------------------------------- /packages/utilities/src/number/min-by.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/minBy'; -------------------------------------------------------------------------------- /packages/utilities/src/number/percentage.ts: -------------------------------------------------------------------------------- 1 | import round from './round'; 2 | 3 | export default function(value: number, precision: number = 0): string { 4 | return `${round(value * 100, precision)}%`; 5 | } -------------------------------------------------------------------------------- /packages/utilities/src/number/round.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/round'; -------------------------------------------------------------------------------- /packages/utilities/src/object/clone-lazy.ts: -------------------------------------------------------------------------------- 1 | export default function cloneLazy(value: T): T { 2 | return JSON.parse(JSON.stringify(value)); 3 | } -------------------------------------------------------------------------------- /packages/utilities/src/object/merge-with.ts: -------------------------------------------------------------------------------- 1 | import mergeWith from 'lodash/mergeWith'; 2 | 3 | export default function(...args) { 4 | return mergeWith({}, ...args); 5 | } -------------------------------------------------------------------------------- /packages/utilities/src/object/merge.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge'; 2 | 3 | // Convert merge to be immutable 4 | export default function(...sources) { 5 | return merge({}, ...sources); 6 | }; -------------------------------------------------------------------------------- /packages/utilities/src/object/transform.ts: -------------------------------------------------------------------------------- 1 | import functionIdentity from '../function/identity'; 2 | import typeIsArray from '../type/is-array'; 3 | import typeIsFunction from '../type/is-function'; 4 | import typeIsNil from '../type/is-nil'; 5 | import typeIsPlainObject from '../type/is-plain-object'; 6 | 7 | type Transformer = (value: any, key?: PropertyKey, input?: T) => any; 8 | type SchemaValue = any | any[] | Transformer | Object; 9 | 10 | interface Schema { 11 | [key: string]: Transformer | Schema | Schema[] 12 | } 13 | 14 | function getTransformer(schemaValue: SchemaValue, baseTransformer: Transformer): Transformer { 15 | switch (true) { 16 | case typeIsFunction(schemaValue): 17 | return schemaValue; 18 | case typeIsArray(schemaValue): 19 | return value => transformArray(value, schemaValue, baseTransformer); 20 | case typeIsPlainObject(schemaValue): 21 | return value => transformObject(value, schemaValue, baseTransformer); 22 | default: 23 | return baseTransformer; 24 | } 25 | } 26 | 27 | function transformArray(input: any[], schemaValue: any[], baseTransformer: Transformer): any[] { 28 | const transformer = getTransformer(schemaValue[0], baseTransformer); 29 | 30 | return input.map(transformer); 31 | } 32 | 33 | function transformObject(input: T, schema: Schema, baseTransformer: Transformer): U { 34 | const output: U = {}; 35 | 36 | for (const key in input) { 37 | const schemaValue = schema[key]; 38 | const transformer = getTransformer(schemaValue, baseTransformer); 39 | const value = transformer(input[key], key, input); 40 | 41 | if (!typeIsNil(value)) { 42 | output[key] = value; 43 | } 44 | } 45 | 46 | return output; 47 | } 48 | 49 | export default function transform(input: T, schema: Schema, baseTransformer: Transformer = functionIdentity): U { 50 | return transformObject(input, schema, baseTransformer); 51 | } -------------------------------------------------------------------------------- /packages/utilities/src/scale/_base/scale.ts: -------------------------------------------------------------------------------- 1 | import numberClamp from '../../number/clamp'; 2 | 3 | type Calculation = (value: T) => number; 4 | 5 | export interface IScale { 6 | (value: T, clamp?: boolean): number, 7 | domain: T[], 8 | range: number[], 9 | } 10 | 11 | export default function scale(domain: T[], range: number[], calculation: Calculation): IScale { 12 | const [ 13 | min, 14 | max 15 | ] = range; 16 | 17 | const output: IScale = (value: T, clamp: boolean) => { 18 | let result = calculation(value); 19 | 20 | if (clamp) { 21 | result = numberClamp(result, min, max); 22 | } 23 | 24 | return result; 25 | }; 26 | 27 | output.domain = domain; 28 | output.range = range; 29 | 30 | return output; 31 | } -------------------------------------------------------------------------------- /packages/utilities/src/scale/continuous.ts: -------------------------------------------------------------------------------- 1 | import scale from './_base/scale'; 2 | 3 | import type { 4 | IScale 5 | } from './_base/scale'; 6 | 7 | export default function continuous( 8 | domain: number[], 9 | range: number[], 10 | ): IScale { 11 | const [ 12 | domainMin, 13 | domainMax 14 | ] = domain; 15 | 16 | const [ 17 | rangeMin, 18 | rangeMax 19 | ] = range; 20 | 21 | const domainLength = domainMax - domainMin; 22 | const rangeLength = rangeMax - rangeMin; 23 | 24 | return scale(domain, range, value => { 25 | return (value - domainMin) * rangeLength / domainLength + rangeMin; 26 | }); 27 | }; -------------------------------------------------------------------------------- /packages/utilities/src/scale/discrete.ts: -------------------------------------------------------------------------------- 1 | import scale from './_base/scale'; 2 | 3 | import type { 4 | IScale 5 | } from './_base/scale'; 6 | 7 | export default function discrete( 8 | domain: T[], 9 | range: number[], 10 | ): IScale { 11 | const [ 12 | rangeMin, 13 | rangeMax 14 | ] = range; 15 | 16 | const rangeLength = rangeMax - rangeMin; 17 | const domainLength = domain.length; 18 | const step = rangeLength / domainLength; 19 | 20 | return scale(domain, range, value => { 21 | return rangeMin + (domain.indexOf(value) * step); 22 | }); 23 | }; -------------------------------------------------------------------------------- /packages/utilities/src/string/capitalize.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/capitalize'; -------------------------------------------------------------------------------- /packages/utilities/src/string/unique-id.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | 3 | export default function uniqueId(length: number = 6): string { 4 | return nanoid(length); 5 | } -------------------------------------------------------------------------------- /packages/utilities/src/type/is-array.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isArray'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-date.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isDate'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-function.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isFunction'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-nil.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isNil'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-number.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isNumber'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-plain-object.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isPlainObject'; -------------------------------------------------------------------------------- /packages/utilities/src/type/is-string.ts: -------------------------------------------------------------------------------- 1 | export { default } from 'lodash/isString'; -------------------------------------------------------------------------------- /packages/utilities/src/value/get-accessor.ts: -------------------------------------------------------------------------------- 1 | import isNil from '../type/is-nil'; 2 | import isFunction from '../type/is-function'; 3 | import noop from '../function/noop'; 4 | 5 | type Product = (...args: any[]) => T; 6 | 7 | export default function getAccessor(identity: T | Product): Product { 8 | if (isNil(identity)) { 9 | return noop as any; 10 | } 11 | 12 | return isFunction(identity) ? identity : () => identity; 13 | } -------------------------------------------------------------------------------- /packages/utilities/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "allowSyntheticDefaultImports": true, 8 | "noImplicitAny": false, 9 | "resolveJsonModule": true 10 | }, 11 | "exclude": [ 12 | "public", 13 | "dist", 14 | "node_modules" 15 | ] 16 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "env": { 4 | "OWM_API_KEY": "@owm-api-key", 5 | "WORLDTIDES_API_KEY": "@worldtides-api-key", 6 | "MAPBOX_API_KEY": "@mapbox-api-key", 7 | "GA_TRACKING_ID": "@ga-tracking-id", 8 | "SENTRY_DSN": "@sentry-dsn" 9 | }, 10 | "build": { 11 | "env": { 12 | "OWM_API_KEY": "@owm-api-key", 13 | "WORLDTIDES_API_KEY": "@worldtides-api-key", 14 | "MAPBOX_API_KEY": "@mapbox-api-key", 15 | "GA_TRACKING_ID": "@ga-tracking-id", 16 | "SENTRY_DSN": "@sentry-dsn" 17 | } 18 | }, 19 | "routes": [ 20 | { 21 | "handle": "filesystem" 22 | }, 23 | { 24 | "src": "/.*", 25 | "dest": "/index.html" 26 | } 27 | ] 28 | } --------------------------------------------------------------------------------