Repository: andrewcourtice/ocula
Branch: master
Commit: f49a46a1b83b
Files: 290
Total size: 247.0 KB
Directory structure:
gitextract_fpbhpnbu/
├── .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/
│ │ │ └── index.ts
│ │ ├── 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/
│ │ │ │ ├── index.ts
│ │ │ │ ├── index.vue
│ │ │ │ └── not-found.vue
│ │ │ ├── error.vue
│ │ │ ├── forecast/
│ │ │ │ ├── index.ts
│ │ │ │ └── index.vue
│ │ │ ├── forecast.vue
│ │ │ ├── index.ts
│ │ │ ├── maps/
│ │ │ │ ├── index.ts
│ │ │ │ └── index.vue
│ │ │ ├── maps.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
│ │ │ └── settings.vue
│ │ ├── 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
│ │ │ ├── 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/
│ │ │ │ └── icon.vue
│ │ │ ├── icon-button/
│ │ │ │ └── icon-button.vue
│ │ │ ├── icon-label/
│ │ │ │ └── icon-label.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
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: ocula
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: monthly
day: wednesday
time: "12:00"
timezone: Australia/Brisbane
open-pull-requests-limit: 10
================================================
FILE: .gitignore
================================================
node_modules/
jspm_packages/
typings/
public/
.cache
.env
.env.build
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.now
.DS_Store
================================================
FILE: .vscode/settings.json
================================================
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/*.log": true
},
"npm.packageManager": "yarn",
"vetur.experimental.templateInterpolationService": true
}
================================================
FILE: BACKERS.md
================================================
# Backers
Kerry Tarrant
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019 Andrew Courtice
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Ocula
The free and open-source progressive weather app
- [About](#about)
- [Features](#features)
- [Philosophy](#philosophy)
- [Donating](#donating)
- [Credits](#credits)
## About
Ocula is a weather app built entirely using modern web standards in an attempt to create a great looking weather app that anyone can use on any device while also providing a simple PWA template for developers to build upon.
I set out to create Ocula as a replacement for my favourite weather app - Pocket Weather, which was unfortunately shut down at the end of 2019 due to high maintenance costs.
## Features
- No location restrictions - available worldwide
- Daily forecast for up to 8 days
- Hourly forecast data for up to 24 hours
- Trend charts for hourly temp, rainfall and wind
- Ocean tide information with tide height trend chart
- Interactive weather maps with 6 different map types (radar, precipitation, temp, cloud, wind, pressure)
- Frame-by-frame playback for radar images to visualise incoming rain
- Dark/Light Themes. Default theme changes based on current time of day
- Options to reorder or hide forecast sections, set your prefferred map type, units and more
- Open-source, privacy friendly, and best of all - free
## Philosophy
The goal of this project is to satisfy the following:
- Must be open-source and freely available to all.
- Must be ad-free, subscription-free and any revenue generated to be used for ongoing maintenance costs.
- Must be built entirely using free (or freemium) services/assets (including hosting, api's, graphics etc.).
- Must be fast, lightweight, accessible and beautiful.
It is my hope that by satisfying the above conditions Ocula can be a weather app for all to enjoy without being bombarded with ads and signups.
However, as a result of satisfying the above conditions it is therefore not sustainable without some form of monetisation. For the most part I use free tiers of various services to ensure the app remains free but with increased usage I will personally incur the cost and may be forced to shutdown the service should costs become burdensome. For this reason I ask that you consider one of the following:
- If you like Ocula and use it as your everyday weather app I ask that you please consider contributing a regular small donation to the project (see [donating](#donating)) to help ease the cost of maintenance.
- If you are a developer you are free to fork this repository and host your own copy in accordance with the MIT licence.
## Donating
Please consider donating to the ongoing development of this project by visiting my [Patreon page](https://www.patreon.com/ocula).
## Credits
- Weather forecast provided by [OpenWeatherMap](https://openweathermap.org).
- Tidal information provided by [WorldTides](https://www.worldtides.info).
- Precipitation map tiles provided by [RainViewer](https://www.rainviewer.com).
- Maps and geocoding services provided by [MapBox](https://www.mapbox.com).
- Logo designed by [Ethan Roxburgh](https://github.com/ethanroxburgh).
- Icons provided by [Remix Icons](https://remixicon.com).
================================================
FILE: _config.yml
================================================
theme: jekyll-theme-cayman
================================================
FILE: api/_helpers/camel-case-keys.ts
================================================
import toCamelCase from './to-camel-case';
export default function camelCaseKeys(data: Record): Record {
const output = {};
for (const key in data) {
let value = data[key];
const camelKey = toCamelCase(key);
switch (true) {
case Array.isArray(value):
value = value.map(camelCaseKeys);
break;
case typeof value === 'object':
value = camelCaseKeys(value);
break;
}
output[camelKey] = value;
}
return output;
}
================================================
FILE: api/_helpers/to-camel-case.ts
================================================
export default function(value: string): string {
return value.toLowerCase().replace(/([-_]\w)/g, group => group[1].toUpperCase());
}
================================================
FILE: api/location/_helpers/map-location.ts
================================================
export default function(feature) {
const {
id,
text,
place_name,
center
} = feature;
return {
id,
shortName: text,
longName: place_name,
latitude: center[1],
longitude: center[0]
};
}
================================================
FILE: api/location/coordinates.ts
================================================
import fetch from 'node-fetch';
import mapLocation from './_helpers/map-location';
import {
NowRequest,
NowResponse
} from '@vercel/node';
export default async function (request: NowRequest, response: NowResponse) {
const {
latitude,
longitude
} = request.query;
const apiKey = process.env.MAPBOX_API_KEY;
const apiResponse = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${longitude},${latitude}.json?access_token=${apiKey}&types=locality,place&limit=1`);
const {
features
} = await apiResponse.json();
if (!features || features.length < 1) {
response.statusCode = 500;
}
const output = mapLocation(features[0]);
return response.json(output);
}
================================================
FILE: api/location/search.ts
================================================
import fetch from 'node-fetch';
import mapLocation from './_helpers/map-location';
import {
NowRequest,
NowResponse
} from '@vercel/node';
export default async function (request: NowRequest, response: NowResponse) {
const {
query
} = request.query;
const apiKey = process.env.MAPBOX_API_KEY;
const apiResponse = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${query}.json?access_token=${apiKey}&types=locality,place&limit=5&autocomplete=true`);
const {
features
} = await apiResponse.json();
if (!features || features.length < 1) {
response.statusCode = 500;
}
const output = features.map(mapLocation);
return response.json(output);
}
================================================
FILE: api/package.json
================================================
{
"name": "@ocula/api",
"version": "1.0.0",
"repository": "https://github.com/andrewcourtice/ocula.git",
"author": "Andrew Courtice",
"license": "MIT",
"dependencies": {
"@vercel/node": "^1.8.5",
"lodash": "^4.17.20",
"node-fetch": "^2.6.1"
}
}
================================================
FILE: api/tsconfig.json
================================================
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs"
}
}
================================================
FILE: api/weather/forecast.ts
================================================
import fetch from 'node-fetch';
import camelCaseKeys from '../_helpers/camel-case-keys';
import {
NowRequest,
NowResponse
} from '@vercel/node';
export default async function (request: NowRequest, response: NowResponse) {
let {
latitude,
longitude,
units
} = request.query;
units = units || 'metric';
const owmApiKey = process.env.OWM_API_KEY;
const worldtidesApiKey = process.env.WORLDTIDES_API_KEY;
const responses = await Promise.all([
fetch(`https://api.openweathermap.org/data/2.5/onecall?appid=${owmApiKey}&lat=${latitude}&lon=${longitude}&units=${units}&exclude=minutely`),
fetch(`https://www.worldtides.info/api/v2?heights&extremes&date=today&days=1&step=3600&lat=${latitude}&lon=${longitude}&key=${worldtidesApiKey}`),
fetch('https://tilecache.rainviewer.com/api/maps.json')
]);
let [
forecast,
tides,
timestamps
] = await Promise.all(responses.map(response => response.json()));
forecast = camelCaseKeys(forecast);
return response.json({
...forecast,
tides,
radar: {
timestamps
}
});
}
================================================
FILE: client/.browserslistrc
================================================
chrome >= 58
firefox >= 54
edge >= 18
safari >= 11
ios_saf >= 11
opera >= 55
================================================
FILE: client/babel.config.js
================================================
module.exports = {
presets: [
['@babel/env', {
useBuiltIns: 'entry',
corejs: 3
}],
'@babel/typescript'
],
plugins: [
['const-enum', {
transform: 'constObject'
}],
'@babel/transform-typescript'
]
};
================================================
FILE: client/build/_base/config.js
================================================
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import FaviconsWebpackPlugin from 'favicons-webpack-plugin';
import {
CleanWebpackPlugin
} from 'clean-webpack-plugin';
import {
VueLoaderPlugin
} from 'vue-loader';
import webpack from 'webpack';
export default {
stats: 'minimal',
entry: {
app: './src/index.ts'
},
output: {
path: path.resolve(__dirname, '../../../public'),
publicPath: '/',
},
performance: {
hints: false
},
resolve: {
extensions: ['.ts', '.js'],
alias: {
'components': path.resolve(__dirname, '../src/components'),
'constants': path.resolve(__dirname, '../src/constants'),
'controllers': path.resolve(__dirname, '../src/controllers'),
'store': path.resolve(__dirname, '../src/store'),
},
symlinks: false
},
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.(ts|js)x?$/,
loader: 'babel-loader',
exclude: {
test: /node_modules/,
not: [
/@ocula/,
/.*\.vue\.(js|ts)$/
]
}
},
{
test: /\.(png|jpg|gif|svg|woff|woff2|eot|ttf)$/,
loader: 'url-loader',
options: {
limit: 8192
}
},
{
test: /\.html$/,
loader: 'html-loader'
}
]
},
plugins: [
new webpack.EnvironmentPlugin({
'MAPBOX_API_KEY': '',
'WORLDTIDES_API_KEY': '',
'OWM_API_KEY': '',
'GA_TRACKING_ID': '',
'SENTRY_DSN': '',
}),
new webpack.DefinePlugin({
'__VUE_OPTIONS_API__': false,
'__VUE_PROD_DEVTOOLS__': false
}),
new CleanWebpackPlugin(),
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
title: 'Ocula',
template: './src/index.ejs'
}),
new FaviconsWebpackPlugin({
logo: './src/assets/images/logo/logo-512.svg',
favicons: {
appName: 'Ocula',
appShortName: 'Ocula',
appDescription: 'The open-source, progressive weather app',
developerName: 'Andrew Courtice',
display: 'standalone',
background: '#FFFFFF',
theme_color: '#FFFFFF',
appleStatusBarStyle: 'default',
start_url: '/?source=pwa',
scope: '/',
icons: {
android: true,
appleIcon: true,
appleStartup: true,
favicons: true,
firefox: true,
windows: true
}
}
}),
new CopyWebpackPlugin([
'./src/static'
])
]
};
================================================
FILE: client/build/_base/workbox.js
================================================
export default {
swDest: 'service-worker.js',
clientsClaim: true,
//skipWaiting: true,
navigateFallback: '/index.html',
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'ocula-fonts'
}
},
{
urlPattern: /.*fontawesome\.com/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'ocula-fonts'
}
}
]
};
================================================
FILE: client/build/development.js
================================================
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import merge from 'webpack-merge';
import base from './_base/config';
const CSS_LOADERS = [
'vue-style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
config: {
path: 'client/'
}
}
}
];
export default merge(base, {
mode: 'development',
devServer: {
port: 3000,
hot: true,
noInfo: true,
historyApiFallback: true,
clientLogLevel: 'warning'
},
output: {
filename: '[name]-[hash].js',
chunkFilename: '[name]-[hash].js'
},
devtool: 'cheap-module-eval-source-map',
module: {
rules: [
{
test: /\.css$/,
use: CSS_LOADERS
},
{
test: /\.scss$/,
use: [].concat(CSS_LOADERS, 'sass-loader'),
exclude: {
test: /node_modules/,
not: [
/@ocula/
]
}
},
{
test: /\.sass$/,
use: [].concat(CSS_LOADERS, 'sass-loader?indentedSyntax'),
exclude: {
test: /node_modules/,
not: [
/@ocula/
]
}
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name]-[hash].css',
chunkFilename: '[name]-[hash].css'
})
]
});
================================================
FILE: client/build/insights.js
================================================
import merge from 'webpack-merge';
import production from './production';
import {
BundleAnalyzerPlugin
} from 'webpack-bundle-analyzer';
export default merge(production, {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'ocula-bundle-report.html'
})
]
});
================================================
FILE: client/build/production.js
================================================
import TerserPlugin from 'terser-webpack-plugin';
import OptimiseCSSPlugin from 'optimize-css-assets-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import WorkboxPlugin from 'workbox-webpack-plugin';
import merge from 'webpack-merge';
import base from './_base/config';
import workboxConfig from './_base/workbox';
const CSS_LOADERS = [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'postcss-loader',
options: {
config: {
path: 'client/'
}
}
}
];
export default merge(base, {
mode: 'production',
output: {
filename: '[name]-[contenthash].js',
chunkFilename: '[name]-[contenthash].js'
},
devtool: 'source-map',
optimization: {
runtimeChunk: 'single',
splitChunks: {
automaticNameDelimiter: '-',
cacheGroups: {
vendor: {
test: (module) => module.context && module.context.includes('node_modules') && !module.context.includes('@ocula'),
name: 'vendor',
chunks: 'initial',
enforce: true
}
}
},
minimizer: [
new TerserPlugin(),
new OptimiseCSSPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: CSS_LOADERS
},
{
test: /\.scss$/,
use: [].concat(CSS_LOADERS, 'sass-loader'),
exclude: {
test: /node_modules/,
not: [
/@ocula/
]
}
},
{
test: /\.sass$/,
use: [].concat(CSS_LOADERS, 'sass-loader?indentedSyntax'),
exclude: {
test: /node_modules/,
not: [
/@ocula/
]
}
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name]-[contenthash].css',
chunkFilename: '[name]-[contenthash].css'
}),
new WorkboxPlugin.GenerateSW(workboxConfig)
]
});
================================================
FILE: client/package.json
================================================
{
"name": "@ocula/client",
"title": "Ocula",
"version": "1.0.3",
"main": "./src/index.ts",
"repository": "https://github.com/andrewcourtice/ocula.git",
"author": "Andrew Courtice",
"description": "The free and open-source progressive weather app",
"license": "MIT",
"scripts": {
"dev": "webpack --env=development",
"build": "webpack --env=production",
"start": "webpack-dev-server --env=development",
"insights": "webpack --env=insights"
},
"dependencies": {
"@ocula/charts": "1.0.0",
"@ocula/components": "1.0.0",
"@ocula/event-emitter": "1.0.0",
"@ocula/router": "1.0.0",
"@ocula/state": "1.0.0",
"@ocula/utilities": "1.0.0",
"@sentry/browser": "^5.29.0",
"@sentry/integrations": "^5.29.0",
"core-js": "3.8.1",
"regenerator-runtime": "^0.13.7",
"vue": "3.0.5",
"workbox-window": "^6.0.2"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-typescript": "^7.12.1",
"@babel/preset-env": "^7.12.7",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.1",
"@types/d3": "^6.2.0",
"@vue/compiler-sfc": "3.0.5",
"autoprefixer": "^9.8.6",
"babel-loader": "^8.1.0",
"babel-plugin-const-enum": "^1.0.1",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.6.0",
"favicons-webpack-plugin": "^3.0.1",
"file-loader": "^6.1.0",
"html-loader": "^1.3.0",
"html-webpack-plugin": "^4.4.1",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.14.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-loader": "^3.0.0",
"sass-loader": "^8.0.2",
"style-loader": "^1.2.1",
"terser-webpack-plugin": "^2.3.5",
"url-loader": "^4.1.0",
"vue-loader": "16.1.1",
"vue-style-loader": "^4.1.2",
"webpack": "^4.44.1",
"webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2",
"workbox-webpack-plugin": "^5.1.4"
}
}
================================================
FILE: client/postcss.config.js
================================================
module.exports = {
plugins: [
require('autoprefixer')
]
};
================================================
FILE: client/src/app.vue
================================================
{{ route.label }}
================================================
FILE: client/src/assets/images/figures/index.ts
================================================
import fog from './fog.svg';
import fullMoon from './full-moon.svg';
import lightRain from './light-rain.svg';
import lightSnow from './light-snow.svg';
import moderateRain from './moderate-rain.svg';
import nightWind from './night-wind.svg';
import partlyCloudyDay from './partly-cloudy-day.svg';
import partlyCloudyNight from './partly-cloudy-night.svg';
import rain from './rain.svg';
import rainCloud from './rain-cloud.svg';
import rainyNight from './rainy-night.svg';
import snow from './snow.svg';
import snowySunnyDay from './snowy-sunny-day.svg';
import storm from './storm.svg';
import stormyNight from './stormy-night.svg';
import stormyWeather from './stormy-weather.svg';
import sun from './sun.svg';
import weather from './weather.svg';
import wind from './wind.svg';
import windyWeather from './windy-weather.svg';
export default {
fog,
fullMoon,
lightRain,
lightSnow,
moderateRain,
nightWind,
partlyCloudyDay,
partlyCloudyNight,
rain,
rainCloud,
rainyNight,
snow,
snowySunnyDay,
storm,
stormyNight,
stormyWeather,
sun,
weather,
wind,
windyWeather
};
================================================
FILE: client/src/components/charts/_base/chart.ts
================================================
import EVENTS from '../../../constants/core/events';
import {
defineComponent,
ref,
watch,
onMounted,
onBeforeUnmount,
WatchStopHandle,
} from 'vue';
import {
useSubscriber
} from '@ocula/components';
export default function chart(Chart) {
return defineComponent({
props: {
data: {
type: Array,
default: () => []
},
options: {
type: Object
},
autoRender: {
type: Boolean,
default: true
},
autoUpdate: {
type: Boolean,
default: true
}
},
setup(props) {
let chart;
let watchHandle: WatchStopHandle;
const element = ref(null);
async function render() {
if (!chart) {
return;
}
return chart.render(props.data, props.options);
}
useSubscriber(EVENTS.application.resized, render);
onMounted(() => {
chart = new Chart(element.value);
if (props.autoRender) {
render();
}
if (props.autoUpdate) {
watchHandle = watch([
() => props.data,
() => props.options
], render);
}
});
onBeforeUnmount(() => watchHandle && watchHandle());
return {
element
};
}
});
}
================================================
FILE: client/src/components/charts/line.vue
================================================
================================================
FILE: client/src/components/charts/trends.vue
================================================
================================================
FILE: client/src/components/drawers/maps.vue
================================================
================================================
FILE: client/src/components/forecast/daily-forecast.vue
================================================
{{ day.temp.min.formatted }}
{{ day.temp.max.formatted }}
{{ day.windSpeed.formatted }}
{{ day.windDeg.formatted }}
{{ day.humidity.formatted }}
{{ day.pressure.formatted }}
{{ day.clouds.formatted }}
================================================
FILE: client/src/components/forecast/hourly-forecast.vue
================================================
{{ getTime(column.value) }}
{{ column.value.windDeg.formatted }}
{{ column.value.weather.description.formatted }}
================================================
FILE: client/src/components/forecast/summary.vue
================================================
{{ forecast.current.temp.formatted }}
Feels like {{ forecast.current.feelsLike.formatted }}
{{ forecast.current.weather.description.formatted }}
Updated {{ lastUpdated }} ago
================================================
FILE: client/src/components/forecast/tides.vue
================================================
{{ getTime(data.value) }}
{{ getTime(column.value) }}
{{ getTime(data.value) }}
All tide measurements are displayed in metres (m)
================================================
FILE: client/src/components/forecast/today.vue
================================================
{{ observation.label }}
{{ observation.value }}
================================================
FILE: client/src/components/forecast/uv-index.vue
================================================
================================================
FILE: client/src/components/layouts/settings.vue
================================================
================================================
FILE: client/src/components/layouts/weather.vue
================================================
Welcome to Ocula
Get started by setting a location.
You can set a location by either searching for a place you know or using your current GPS position.
Set location
================================================
FILE: client/src/components/modals/location.vue
================================================
================================================
FILE: client/src/components/settings/settings-item.vue
================================================
================================================
FILE: client/src/components/weather/actions.vue
================================================
{{ location.shortName }}
Unknown
================================================
FILE: client/src/components/weather/observation.vue
================================================
================================================
FILE: client/src/constants/core/data.ts
================================================
export default {
lastUpdated: null,
location: null,
forecast: null
};
================================================
FILE: client/src/constants/core/drawers.ts
================================================
export default {
maps: 'drawer:maps'
} as const;
================================================
FILE: client/src/constants/core/events.ts
================================================
export default {
application: {
visible: 'application:visible',
resized: 'application:resized'
},
storage: {
dataSaved: 'storage:data-saved',
settingsSaved: 'storage:settings-saved'
},
location: {
set: 'location:set'
}
};
================================================
FILE: client/src/constants/core/global.ts
================================================
export default {
updateThreshold: 1800000
};
================================================
FILE: client/src/constants/core/migrations.ts
================================================
import SETTINGS from '../core/settings';
import {
arrayUnionWith,
objectTransform
} from '@ocula/utilities';
import type {
ISettings
} from '../../types/storage';
type Migration = (settings: ISettings) => ISettings;
/*
Dictionary to migrate settings structures to new versions.
For example, removing a section using the object transformer:
{
3: settings => objectTransform(settings, {
forecast: {
sections: sections => sections.filter(({ type }) => type !== 'today')
}
})
}
*/
export default {
'1': settings => objectTransform(settings, {
forecast: {
sections: sections => arrayUnionWith(sections, SETTINGS.forecast.sections, (a, b) => a.type === b.type)
}
})
} as Record
================================================
FILE: client/src/constants/core/modals.ts
================================================
export default {
locations: 'modal:locations'
} as const;
================================================
FILE: client/src/constants/core/routes.ts
================================================
export default {
forecast: {
index: 'forecast:index',
},
maps: {
index: 'maps:index'
},
settings: {
index: 'settings:index',
forecast: {
locations: 'settings:forecast:locations',
sections: 'settings:forecast:sections',
},
maps: {
display: 'settings:maps:display'
},
general: {
theme: 'settings:general:theme',
about: 'settings:general:about'
}
},
error: {
index: 'error:index',
notFound: 'error:not:found'
}
} as const;
================================================
FILE: client/src/constants/core/settings.ts
================================================
import UNITS from '../../enums/forecast/units';
import MAP from '../../enums/maps/map';
import FORECAST_SECTION from '../../enums/forecast/section';
import type {
ISettings
} from '../../types/storage';
export default {
version: 1.1,
units: UNITS.metric,
theme: 'default',
location: null,
locations: [],
forecast: {
sections: [
{
type: FORECAST_SECTION.today,
visible: true
},
{
type: FORECAST_SECTION.dailyForecast,
visible: true
},
{
type: FORECAST_SECTION.hourlyForecast,
visible: true
},
{
type: FORECAST_SECTION.uvIndex,
visible: true
},
{
type: FORECAST_SECTION.tides,
visible: true
}
]
},
maps: {
default: MAP.radar,
zoom: 6,
pitch: 0,
framerate: 500
}
} as ISettings;
================================================
FILE: client/src/constants/core/storage-keys.ts
================================================
export default {
data: 'ocula:data',
settings: 'ocula:settings'
};
================================================
FILE: client/src/constants/forecast/directions.ts
================================================
export default [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW'
];
================================================
FILE: client/src/constants/forecast/figure.ts
================================================
import figures from '../../assets/images/figures';
import PHASE from '../../enums/forecast/phase';
interface IFigure extends Record {};
export default {
[PHASE.day]: {
200: figures.storm,
300: figures.lightRain,
500: figures.rain,
600: figures.snow,
700: figures.partlyCloudyDay,
800: figures.sun,
801: figures.partlyCloudyDay,
802: figures.partlyCloudyDay,
803: figures.partlyCloudyDay,
804: figures.partlyCloudyDay,
},
[PHASE.night]: {
200: figures.stormyNight,
300: figures.rainyNight,
500: figures.rainyNight,
600: figures.snow,
700: figures.partlyCloudyNight,
800: figures.fullMoon,
801: figures.partlyCloudyNight,
802: figures.partlyCloudyNight,
803: figures.partlyCloudyNight,
804: figures.partlyCloudyNight,
}
} as Record;
================================================
FILE: client/src/constants/forecast/formats.ts
================================================
import UNITS from '../../enums/forecast/units';
import FORMATTERS, {
defaultFormatter
} from './formatters';
import {
objectMerge,
objectTransform
} from '@ocula/utilities';
import type {
IForecastWeather
} from '../../types/weather';
const {
general,
temperature,
distance,
speed,
pressure,
direction
} = FORMATTERS;
function weatherTransform(value: IForecastWeather[]): Record {
return objectTransform(value[0], {
description: general.description
}, defaultFormatter);
}
const BASE_FORMATS = {
current: {
dt: general.datetime,
sunrise: general.datetime,
sunset: general.datetime,
humidity: general.percentage,
clouds: general.percentage,
windDeg: direction.bearing,
weather: weatherTransform
},
tides: {
heights: [
{
dt: general.datetime,
height: distance.metres
}
],
extremes: [
{
dt: general.datetime,
height: distance.metres
}
]
},
radar: {
timestamps: [
general.datetime
]
}
};
export default {
[UNITS.metric]: objectMerge(BASE_FORMATS, {
current: {
temp: temperature.celcius,
feelsLike: temperature.celcius,
pressure: pressure.hectopascals,
dewPoint: temperature.celcius,
visibility: distance.metres,
windSpeed: speed.metresPerSecond,
},
daily: [
{
dt: general.datetime,
sunrise: general.datetime,
sunset: general.datetime,
temp: {
day: temperature.celcius,
min: temperature.celcius,
max: temperature.celcius,
night: temperature.celcius,
eve: temperature.celcius,
morn: temperature.celcius
},
feelsLike: {
day: temperature.celcius,
night: temperature.celcius,
eve: temperature.celcius,
morn: temperature.celcius
},
pressure: pressure.hectopascals,
humidity: general.percentage,
dewPoint: temperature.celcius,
windSpeed: speed.metresPerSecond,
windDeg: direction.bearing,
weather: weatherTransform,
clouds: general.percentage,
rain: distance.millimeters,
pop: general.fractional
}
],
hourly: [
{
dt: general.datetime,
temp: temperature.celcius,
feelsLike: temperature.celcius,
pressure: pressure.hectopascals,
humidity: general.percentage,
dewPoint: temperature.celcius,
clouds: general.percentage,
visibility: distance.metres,
windSpeed: speed.metresPerSecond,
windDeg: direction.bearing,
pop: general.fractional,
weather: weatherTransform,
}
]
}),
[UNITS.imperial]: objectMerge(BASE_FORMATS, {
current: {
temp: temperature.fahrenheit,
feelsLike: temperature.fahrenheit,
pressure: pressure.millibars,
dewPoint: temperature.fahrenheit,
visibility: distance.miles,
windSpeed: speed.milesPerHour,
},
daily: [
{
dt: general.datetime,
sunrise: general.datetime,
sunset: general.datetime,
temp: {
day: temperature.fahrenheit,
min: temperature.fahrenheit,
max: temperature.fahrenheit,
night: temperature.fahrenheit,
eve: temperature.fahrenheit,
morn: temperature.fahrenheit
},
feelsLike: {
day: temperature.fahrenheit,
night: temperature.fahrenheit,
eve: temperature.fahrenheit,
morn: temperature.fahrenheit
},
pressure: pressure.millibars,
humidity: general.percentage,
dewPoint: temperature.fahrenheit,
windSpeed: speed.milesPerHour,
windDeg: direction.bearing,
weather: weatherTransform,
clouds: general.percentage,
rain: distance.inches,
pop: general.fractional
}
],
hourly: [
{
dt: general.datetime,
temp: temperature.fahrenheit,
feelsLike: temperature.fahrenheit,
pressure: pressure.millibars,
humidity: general.percentage,
dewPoint: temperature.fahrenheit,
clouds: general.percentage,
visibility: distance.miles,
windSpeed: speed.milesPerHour,
windDeg: direction.bearing,
pop: general.fractional,
weather: weatherTransform
}
]
})
}
================================================
FILE: client/src/constants/forecast/formatters.ts
================================================
import UNIT_OF_MEASURE from '../../enums/forecast/unit-of-measure';
import getIcon from '../../helpers/get-icon';
import getDirection from '../../helpers/get-direction';
import {
dateFromUnix,
functionIdentity,
stringCapitalize
} from '@ocula/utilities';
function baseFormatter(raw: T, formatted: any) {
return {
raw,
formatted,
toString() {
return formatted;
}
};
};
function toSuffix(suffix: string, transformer: Function = functionIdentity) {
return value => baseFormatter(value, `${transformer(value)}${suffix}`);
}
export function defaultFormatter(value) {
return baseFormatter(value, value);
}
export default {
distance: {
millimeters: toSuffix(UNIT_OF_MEASURE.millimeters),
centimetres: toSuffix(UNIT_OF_MEASURE.centimetres),
metres: toSuffix(UNIT_OF_MEASURE.metres),
kilometres: toSuffix(UNIT_OF_MEASURE.kilometres),
miles: toSuffix(UNIT_OF_MEASURE.miles),
inches: toSuffix(UNIT_OF_MEASURE.inches)
},
speed: {
millimetresPerHour: toSuffix(UNIT_OF_MEASURE.millimetresPerHour),
kilometresPerHour: toSuffix(UNIT_OF_MEASURE.kilometresPerHour),
metresPerSecond: toSuffix(UNIT_OF_MEASURE.metresPerSecond),
inchesPerHour: toSuffix(UNIT_OF_MEASURE.inchesPerHour),
milesPerHour: toSuffix(UNIT_OF_MEASURE.milesPerHour)
},
temperature: {
celcius: toSuffix(UNIT_OF_MEASURE.celcius, Math.round),
fahrenheit: toSuffix(UNIT_OF_MEASURE.fahrenheit, Math.round),
},
pressure: {
hectopascals: toSuffix(UNIT_OF_MEASURE.hectopascals),
millibars: toSuffix(UNIT_OF_MEASURE.millibars)
},
direction: {
bearing: value => baseFormatter(value, getDirection(value))
},
general: {
description: value => baseFormatter(value, stringCapitalize(value)),
datetime: value => baseFormatter(value, dateFromUnix(value)),
icon: value => baseFormatter(value, getIcon(value)),
percentage: toSuffix(UNIT_OF_MEASURE.percentage, value => Math.round(value)),
fractional: toSuffix(UNIT_OF_MEASURE.percentage, value => Math.round(value * 100))
}
};
================================================
FILE: client/src/constants/forecast/icon.ts
================================================
import PHASE from '../../enums/forecast/phase';
export default {
[PHASE.day]: {
200: 'thunderstorms-line',
300: 'drizzle-line',
500: 'showers-line',
502: 'heavy-showers-line',
503: 'heavy-showers-line',
504: 'heavy-showers-line',
511: 'snowy-line',
600: 'snowy-line',
700: 'cloudy-line',
701: 'mist-line',
711: 'haze-line',
721: 'haze-line',
731: 'haze-line',
741: 'sun-foggy-line',
751: 'haze-line',
761: 'haze-line',
781: 'tornado-line',
800: 'sun-line',
801: 'cloudy-line',
802: 'cloudy-line',
803: 'cloudy-line',
804: 'cloudy-line'
},
[PHASE.night]: {
200: 'thunderstorms-line',
300: 'drizzle-line',
500: 'showers-line',
502: 'heavy-showers-line',
503: 'heavy-showers-line',
504: 'heavy-showers-line',
511: 'snowy-line',
600: 'snowy-line',
700: 'moon-cloudy-line',
701: 'mist-line',
711: 'haze-line',
721: 'haze-line',
731: 'haze-line',
741: 'moon-foggy-line',
751: 'haze-line',
761: 'haze-line',
781: 'tornado-line',
800: 'moon-clear-line',
801: 'moon-cloudy-line',
802: 'moon-cloudy-line',
803: 'moon-cloudy-line',
804: 'moon-cloudy-line',
}
};
================================================
FILE: client/src/constants/forecast/sections.ts
================================================
import FORECAST_SECTION from '../../enums/forecast/section';
import DailyForecast from '../../components/forecast/daily-forecast.vue';
import HourlyForecast from '../../components/forecast/hourly-forecast.vue';
import Today from '../../components/forecast/today.vue';
import UvIndex from '../../components/forecast/uv-index.vue';
import Tides from '../../components/forecast/tides.vue';
import type {
Formatted,
IMappedForecast
} from '../../types/state';
interface IForecastSection {
label: string;
component: typeof DailyForecast,
condition?(forecast: Formatted): boolean;
}
export default {
[FORECAST_SECTION.dailyForecast]: {
label: 'Daily Forecast',
component: DailyForecast
},
[FORECAST_SECTION.hourlyForecast]: {
label: 'Hourly Forecast',
component: HourlyForecast
},
[FORECAST_SECTION.today]: {
label: 'Today',
component: Today
},
[FORECAST_SECTION.uvIndex]: {
label: 'UV Index',
component: UvIndex
},
[FORECAST_SECTION.tides]: {
label: 'Tides',
component: Tides,
condition: forecast => forecast.tides && forecast.tides.status.raw === 200
}
} as Record;
================================================
FILE: client/src/constants/forecast/theme.ts
================================================
import {
weather
} from '../../themes';
export default {
200: weather.rainy,
300: weather.rainy,
500: weather.rainy,
600: weather.rainy,
700: weather.partlyCloudy,
800: weather.clear,
801: weather.partlyCloudy,
802: weather.partlyCloudy,
803: weather.partlyCloudy,
804: weather.partlyCloudy,
};
================================================
FILE: client/src/constants/forecast/tides-chart-options.ts
================================================
import {
ILineOptions,
LINE_TYPE
} from '@ocula/charts';
import {
dateFromUnix,
numberRound
} from '@ocula/utilities';
import type {
Formatted
} from '../../types/state';
import type {
IForecastTideHeight
} from '../../types/weather';
type ChartOptions = ILineOptions>;
export default {
type: LINE_TYPE.spline,
scales: {
x: {
type: 'time',
value: ({ dt }) => dateFromUnix(dt.raw)
},
y: {
type: 'linear',
ticks: 5,
value: ({ height }) => height.raw
}
},
labels: {
content: (point, index) => index ? numberRound(point.value.height.raw, 2) : null
},
colours: {
line: '#47B1FA',
marker: '#47B1FA'
}
} as ChartOptions;
================================================
FILE: client/src/constants/forecast/trends.ts
================================================
import TREND from '../../enums/forecast/trend';
import OBSERVATION from '../../enums/forecast/observation';
import {
LINE_TYPE,
SCALE_TYPE,
ILineOptions
} from '@ocula/charts';
import {
objectMerge,
dateFromUnix,
numberPercentage
} from '@ocula/utilities';
import type {
IForecastHour
} from '../../types/weather';
import {
Formatted
} from '../../types/state';
type ChartOptions = ILineOptions>;
interface ITrend {
icon: string;
label: string;
observation: OBSERVATION;
chartOptions: ChartOptions;
}
const BASE_OPTIONS = {
type: LINE_TYPE.spline,
scales: {
x: {
type: SCALE_TYPE.time,
value: ({ dt }) => dateFromUnix(dt.raw)
},
y: {
type: SCALE_TYPE.linear,
ticks: 5
}
},
labels: {
content: (point, index) => index ? Math.round(point.yValue) : null
},
colours: {
tick: '#AAA'
},
padding: {
bottom: 0
}
} as ChartOptions;
export default {
[TREND.temperature]: {
icon: 'temp-cold-line',
label: 'Temperature',
observation: OBSERVATION.temperature,
chartOptions: objectMerge(BASE_OPTIONS, {
scales: {
y: {
value: ({ temp }) => temp.raw
}
},
colours: {
line: '#47B1FA',
marker: '#47B1FA'
}
})
},
[TREND.rainfall]: {
icon: 'rainy-line',
label: 'Precipitation',
observation: OBSERVATION.precipitation,
chartOptions: objectMerge(BASE_OPTIONS, {
type: LINE_TYPE.step,
scales: {
y: {
value: ({ pop }) => pop.raw
}
},
labels: {
content: (point, index) => index ? numberPercentage(point.yValue, 1) : null
},
colours: {
line: '#47B1FA',
marker: '#47B1FA'
}
})
},
[TREND.wind]: {
icon: 'windy-line',
label: 'Wind',
observation: OBSERVATION.windSpeed,
chartOptions: objectMerge(BASE_OPTIONS, {
scales: {
y: {
value: ({ windSpeed }) => windSpeed.raw
}
},
colours: {
line: '#47B1FA',
marker: '#47B1FA'
}
})
}
} as Record;
================================================
FILE: client/src/constants/forecast/unit-of-measure.ts
================================================
import UNITS from '../../enums/forecast/units';
import OBSERVATION from '../../enums/forecast/observation';
import UNIT_OF_MEASURE from '../../enums/forecast/unit-of-measure';
type UnitOfMeasure = Record
export default {
[UNITS.metric]: {
[OBSERVATION.temperature]: UNIT_OF_MEASURE.celcius,
[OBSERVATION.pressure]: UNIT_OF_MEASURE.hectopascals,
[OBSERVATION.windSpeed]: UNIT_OF_MEASURE.metresPerSecond,
[OBSERVATION.precipitation]: UNIT_OF_MEASURE.percentage
},
[UNITS.imperial]: {
[OBSERVATION.temperature]: UNIT_OF_MEASURE.fahrenheit,
[OBSERVATION.pressure]: UNIT_OF_MEASURE.millibars,
[OBSERVATION.windSpeed]: UNIT_OF_MEASURE.milesPerHour,
[OBSERVATION.precipitation]: UNIT_OF_MEASURE.percentage
}
} as Record
================================================
FILE: client/src/constants/forecast/units.ts
================================================
import UNITS from '../../enums/forecast/units';
export default {
[UNITS.metric]: {
label: 'Metric'
},
[UNITS.imperial]: {
label: 'Imperial'
}
} as const;
================================================
FILE: client/src/constants/forecast/uv-index.ts
================================================
interface IUVIndex {
id: string;
label: string;
start: number;
colour: string;
};
export default [
{
id: 'low',
label: 'Low',
start: 0,
colour: '#3FA72D'
},
{
id: 'moderate',
label: 'Moderate',
start: 3,
colour: '#FFF301'
},
{
id: 'high',
label: 'High',
start: 6,
colour: '#F18B00'
},
{
id: 'veryHigh',
label: 'Very High',
start: 8,
colour: '#E53110'
},
{
id: 'extreme',
label: 'Extreme',
start: 11,
colour: '#B467A4'
}
] as IUVIndex[];
================================================
FILE: client/src/constants/maps/maps.ts
================================================
import MAP from '../../enums/maps/map';
import type {
Formatted,
IFormatter,
IMappedForecast
} from '../../types/state';
interface IMapLegend {
colour: string;
label: string;
}
interface IMapLayer {
id: string;
url: string;
label?: string;
}
interface IMap {
label: string;
icon: string;
layers: IMapLayer[] | (() => IMapLayer[]);
legend?: IMapLegend[];
}
function getOwmTileUrl(layer: string): string {
return `https://tile.openweathermap.org/map/${layer}/{z}/{x}/{y}.png?appid=${process.env.OWM_API_KEY}`;
}
function getRadarLayers(forecast: Formatted, format: IFormatter, smooth: boolean = true, snow: boolean = true): IMapLayer[] {
let timestamps = forecast.radar.timestamps;
return timestamps.map(({ raw, formatted }) => ({
id: raw.toString(),
label: format.time(formatted),
url: `https://tilecache.rainviewer.com/v2/radar/${raw}/256/{z}/{x}/{y}/2/${+smooth}_${+snow}.png`
}));
}
export default {
[MAP.radar]: {
label: 'Radar',
icon: 'radar-line',
layers: getRadarLayers,
legend: [
{
colour: '#8EE',
label: 'Light Drizzle'
},
{
colour: '#09C',
label: 'Drizzle'
},
{
colour: '#07A',
label: 'Light Rain'
},
{
colour: '#058',
label: 'Light Rain'
},
{
colour: '#FE0',
label: 'Rain'
},
{
colour: '#FA0',
label: 'Rain'
},
{
colour: '#F70',
label: 'Heavy Rain'
},
{
colour: '#F40',
label: 'Heavy Rain'
},
{
colour: '#E00',
label: 'Thunderstorm'
},
{
colour: '#900',
label: 'Thunderstorm'
},
{
colour: '#FAF',
label: 'Hail'
},
{
colour: '#F7F',
label: 'Hail'
}
]
},
[MAP.precipitation]: {
label: 'Precipitation',
icon: 'drop-line',
layers: [
{
id: MAP.precipitation,
url: getOwmTileUrl('precipitation_new')
}
]
},
[MAP.temperature]: {
label: 'Temperature',
icon: 'temp-cold-line',
layers: [
{
id: MAP.temperature,
url: getOwmTileUrl('temp_new')
}
]
},
[MAP.cloud]: {
label: 'Cloud',
icon: 'cloudy-line',
layers: [
{
id: MAP.cloud,
url: getOwmTileUrl('clouds_new')
}
]
},
[MAP.wind]: {
label: 'Wind',
icon: 'windy-line',
layers: [
{
id: MAP.wind,
url: getOwmTileUrl('wind_new')
}
]
},
[MAP.pressure]: {
label: 'Pressure',
icon: 'swap-line',
layers: [
{
id: MAP.pressure,
url: getOwmTileUrl('pressure_new')
}
]
}
} as Record;
================================================
FILE: client/src/controllers/application.ts
================================================
import MAP from '../enums/maps/map';
import EVENTS from '../constants/core/events';
import MODALS from '../constants/core/modals';
import DRAWERS from '../constants/core/drawers';
import eventEmitter from '@ocula/event-emitter';
import {
componentsController
} from '@ocula/components';
import {
functionDebounce
} from '@ocula/utilities';
const resize = functionDebounce(event => eventEmitter.emit(EVENTS.application.resized, event), 300);
function visibilityChanged() {
if (!document.hidden) {
eventEmitter.emit(EVENTS.application.visible);
}
}
window.addEventListener('resize', resize)
document.addEventListener('visibilitychange', visibilityChanged);
export class ApplicationController {
constructor() {
}
async setLocation() {
return componentsController.open(MODALS.locations);
}
async setMapType(): Promise {
return componentsController.open(DRAWERS.maps);
}
async notify(title: string, options?: NotificationOptions): Promise {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
}
return new Notification(title, options);
}
}
export default new ApplicationController();
================================================
FILE: client/src/enums/core/status.ts
================================================
const enum STATUS {
loading = 'loading',
error = 'error'
};
export default STATUS;
================================================
FILE: client/src/enums/forecast/location.ts
================================================
const enum LOCATION {
current = 'current'
};
export default LOCATION;
================================================
FILE: client/src/enums/forecast/observation.ts
================================================
const enum OBSERVATION {
temperature = 'temperature',
precipitation = 'precipitation',
humidity = 'humidity',
sunrise = 'sunrise',
sunset = 'sunset',
pressure = 'pressure',
windSpeed = 'windSpeed',
windDirection = 'windDirection',
};
export default OBSERVATION;
================================================
FILE: client/src/enums/forecast/phase.ts
================================================
const enum PHASE {
day = 'day',
night = 'night'
};
export default PHASE;
================================================
FILE: client/src/enums/forecast/section.ts
================================================
const enum FORECAST_SECTION {
today = 'today',
dailyForecast = 'daily-forecast',
hourlyForecast = 'hourly-forecast',
uvIndex = 'uv-index',
tides = 'tides'
};
export default FORECAST_SECTION;
================================================
FILE: client/src/enums/forecast/trend.ts
================================================
const enum TREND {
temperature = 'temperature',
rainfall = 'rainfallprobability',
wind = 'wind'
};
export default TREND;
================================================
FILE: client/src/enums/forecast/unit-of-measure.ts
================================================
const enum UNIT_OF_MEASURE {
// Distance
millimeters = 'mm',
centimetres = 'cm',
metres = 'm',
kilometres = 'km',
miles = 'mi',
inches = 'in',
// Speed
millimetresPerHour = 'mm/h',
kilometresPerHour = 'km/h',
metresPerSecond = 'm/s',
inchesPerHour = 'mi/h',
milesPerHour = 'mi/h',
// Temperature
celcius = '°C',
fahrenheit = '°F',
// Pressure
hectopascals = 'hPa',
millibars = 'bar',
// General
percentage = '%'
};
export default UNIT_OF_MEASURE;
================================================
FILE: client/src/enums/forecast/units.ts
================================================
const enum UNITS {
metric = 'metric',
imperial = 'imperial'
};
export default UNITS;
================================================
FILE: client/src/enums/maps/map.ts
================================================
export const enum MAP {
radar = 'radar',
precipitation = 'precipitation',
temperature = 'temperature',
cloud = 'cloud',
wind = 'wind',
pressure = 'pressure',
};
export default MAP;
================================================
FILE: client/src/helpers/get-direction.ts
================================================
import DIRECTIONS from '../constants/forecast/directions';
const divisor = 360 / DIRECTIONS.length;
export default function getDirection(bearing: number): string {
return DIRECTIONS[Math.floor(bearing / divisor)] || 'Unknown';
}
================================================
FILE: client/src/helpers/get-figure.ts
================================================
import FIGURE from '../constants/forecast/figure';
import {
phase
} from '../store';
export default function getFigure(conditionId: number): string {
const figurePhase = FIGURE[phase.value] || FIGURE.day;
return figurePhase[conditionId] || figurePhase[Math.floor(conditionId / 100) * 100] || figurePhase['800'];
}
================================================
FILE: client/src/helpers/get-icon.ts
================================================
import ICON from '../constants/forecast/icon';
import PHASE from '../enums/forecast/phase';
import getPhase from './get-phase';
export default function getIcon(conditionId: number, timestamp?: number): string {
let phase = PHASE.day;
if (timestamp) {
phase = getPhase(timestamp);
}
const phaseIcon = ICON[phase];
return phaseIcon[conditionId] || phaseIcon[Math.floor(conditionId / 100) * 100] || phaseIcon['800'];
}
================================================
FILE: client/src/helpers/get-phase.ts
================================================
import PHASE from '../enums/forecast/phase';
import {
state
} from '../store';
export default function getPhase(timestamp: number): PHASE {
if (!state.forecast) {
return PHASE.day;
}
const isDay = state.forecast.daily.some(({ sunrise, sunset }) => {
return timestamp > sunrise && timestamp < sunset;
});
return isDay ? PHASE.day : PHASE.night;
}
================================================
FILE: client/src/helpers/set-theme-meta.ts
================================================
import {
domSetMeta
} from '@ocula/utilities';
export default function setThemeMeta(colour: string): void {
domSetMeta('theme-color', colour);
}
================================================
FILE: client/src/index.ejs
================================================
Ocula
<% if (process.env.NODE_ENV === 'production') { %>
<% } %>
Javascript must be enabled to run this app
Ocula
Loading. Please wait.
================================================
FILE: client/src/index.ts
================================================
import start from './startup';
export default start();
================================================
FILE: client/src/routes/error/index.ts
================================================
import ROUTES from '../../constants/core/routes';
import Index from './index.vue';
import NotFound from './not-found.vue';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: '',
name: ROUTES.error.index,
component: Index,
},
{
path: 'not-found',
name: ROUTES.error.notFound,
component: NotFound,
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/error/index.vue
================================================
Error
An error has occurred
================================================
FILE: client/src/routes/error/not-found.vue
================================================
================================================
FILE: client/src/routes/error.vue
================================================
================================================
FILE: client/src/routes/forecast/index.ts
================================================
import ROUTES from '../../constants/core/routes';
import type {
RouteRecordRaw
} from '@ocula/router';
import {
defineAsyncComponent
} from 'vue';
export default [
{
path: '',
name: ROUTES.forecast.index,
component: defineAsyncComponent(() => import(/* webpackChunkName: 'forecast' */ './index.vue'))
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/forecast/index.vue
================================================
================================================
FILE: client/src/routes/forecast.vue
================================================
================================================
FILE: client/src/routes/index.ts
================================================
import Forecast from './forecast.vue';
import Maps from './maps.vue';
import Settings from './settings.vue';
import Error from './error.vue';
import forecast from './forecast';
import maps from './maps';
import settings from './settings';
import error from './error';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: '/forecast',
alias: '/',
component: Forecast,
children: forecast
},
{
path: '/maps',
component: Maps,
children: maps
},
{
path: '/settings',
component: Settings,
children: settings
},
{
path: '/error',
component: Error,
children: error
},
{
path: '/:catchAll(.*)',
redirect: '/error/not-found'
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/maps/index.ts
================================================
import ROUTES from '../../constants/core/routes';
import {
defineAsyncComponent
} from 'vue';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: ':type?',
name: ROUTES.maps.index,
props: true,
component: defineAsyncComponent(() => import(/* webpackChunkName: 'maps' */ './index.vue'))
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/maps/index.vue
================================================
================================================
FILE: client/src/routes/maps.vue
================================================
================================================
FILE: client/src/routes/settings/forecast/index.ts
================================================
import ROUTES from '../../../constants/core/routes';
import Locations from './locations.vue';
import Sections from './sections.vue';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: 'forecast/locations',
name: ROUTES.settings.forecast.locations,
component: Locations
},
{
path: 'forecast/sections',
name: ROUTES.settings.forecast.sections,
component: Sections
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/settings/forecast/locations.vue
================================================
================================================
FILE: client/src/routes/settings/forecast/sections.vue
================================================
================================================
FILE: client/src/routes/settings/general/about.vue
================================================
Version
{{ manifest.version }}
Author
{{ manifest.author }}
Licence
{{ manifest.license }}
Source
Github
Kerry Tarrant
Psalm 147:8
================================================
FILE: client/src/routes/settings/general/index.ts
================================================
import ROUTES from '../../../constants/core/routes';
import Theme from './theme.vue';
import About from './about.vue';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: 'general/theme',
name: ROUTES.settings.general.theme,
component: Theme
},
{
path: 'general/about',
name: ROUTES.settings.general.about,
component: About
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/settings/general/theme.vue
================================================
================================================
FILE: client/src/routes/settings/index.ts
================================================
import ROUTES from '../../constants/core/routes';
import Index from './index.vue';
import forecast from './forecast';
import general from './general';
import maps from './maps';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: '',
name: ROUTES.settings.index,
component: Index
},
...forecast,
...maps,
...general
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/settings/index.vue
================================================
================================================
FILE: client/src/routes/settings/maps/display.vue
================================================
{{ zoom }}
{{ pitch }} °
================================================
FILE: client/src/routes/settings/maps/index.ts
================================================
import ROUTES from '../../../constants/core/routes';
import Display from './display.vue';
import type {
RouteRecordRaw
} from '@ocula/router';
export default [
{
path: 'maps/display',
name: ROUTES.settings.maps.display,
component: Display
}
] as RouteRecordRaw[];
================================================
FILE: client/src/routes/settings.vue
================================================
================================================
FILE: client/src/services/location.ts
================================================
import {
ILocation
} from '../types/location';
export async function searchLocations(query: string): Promise {
const response = await fetch(`/api/location/search?query=${query}`);
return response.json();
}
export async function getLocation(latitude: number, longitude: number): Promise {
const response = await fetch(`/api/location/coordinates?latitude=${latitude}&longitude=${longitude}`);
return response.json();
}
================================================
FILE: client/src/services/weather.ts
================================================
import type {
IForecast
} from '../types/weather';
export async function getForecast(latitude: number, longitude: number, units?: string): Promise {
const response = await fetch(`/api/weather/forecast?latitude=${latitude}&longitude=${longitude}&units=${units}`);
return response.json();
}
================================================
FILE: client/src/startup/application.ts
================================================
import {
createApp
} from 'vue';
import App from '../app.vue';
export default function initialiseApplication() {
return createApp(App);
}
================================================
FILE: client/src/startup/components.ts
================================================
import Components from '@ocula/components';
import type {
App
} from 'vue';
export default function initialiseComponents(application: App) {
return application.use(Components);
}
================================================
FILE: client/src/startup/index.ts
================================================
import './vendor';
import initialiseComponents from './components';
import initialiseRouter from './router';
import initialiseApplication from './application';
import initialiseState from './state';
import initialiseLogging from './logging';
import initialiseWorker from './worker';
export default async function start() {
const application = initialiseApplication();
const router = initialiseRouter(application);
initialiseState(application);
initialiseComponents(application);
initialiseWorker();
initialiseLogging();
await router.isReady();
application.mount('body');
return {
router,
application
};
}
================================================
FILE: client/src/startup/logging.ts
================================================
import {
envIsProduction
} from '@ocula/utilities';
import {
init
} from '@sentry/browser';
// import {
// Vue as SentryVue
// } from '@sentry/integrations';
export default function initialiseLogging() {
init({
enabled: envIsProduction,
dsn: process.env.SENTRY_DSN,
// integrations: [
// new SentryVue({
// Vue,
// attachProps: true
// })
// ]
});
}
================================================
FILE: client/src/startup/router.ts
================================================
import ROUTES from '../constants/core/routes';
import routes from '../routes';
import setThemeMeta from '../helpers/set-theme-meta';
import Router, {
router
} from '@ocula/router';
import type {
App
} from 'vue';
import {
theme
} from '../store';
declare global {
interface Window {
gtag?(key: string, trackingId: string, meta: any): void
}
}
export default function initialiseRouter(application: App) {
application.use(Router, routes);
router.beforeEach((to, from, next) => {
if (!theme.value) {
return next();
}
const isForecast = to.matched.some(({ name }) => name === ROUTES.forecast.index);
let {
colour
} = theme.value.core;
if (isForecast) {
colour = theme.value.weather.colour || colour;
}
setThemeMeta(colour);
next();
});
if ('gtag' in window) {
router.afterEach(to => window.gtag('config', process.env.GA_TRACKING_ID, {
'page_path': to.path
}));
}
return router;
}
================================================
FILE: client/src/startup/state.ts
================================================
import {
plugin
} from '@ocula/state';
import type {
App
} from 'vue';
export default function initialiseComponents(application: App) {
return application.use(plugin);
}
================================================
FILE: client/src/startup/vendor.ts
================================================
import 'core-js/stable';
import 'regenerator-runtime/runtime';
================================================
FILE: client/src/startup/worker.ts
================================================
import {
Workbox,
messageSW
} from 'workbox-window';
import {
clearData
} from '../store/helpers/storage';
import {
componentsController
} from '@ocula/components';
import {
envIsDevelopment
} from '@ocula/utilities';
import type {
WorkboxLifecycleWaitingEvent
} from 'workbox-window/utils/WorkboxEvent';
export default async function initialiseWorker() {
if (envIsDevelopment || !navigator.serviceWorker) {
return;
}
const workbox = new Workbox('/service-worker.js');
let registration: ServiceWorkerRegistration;
async function handleUpdate(event: WorkboxLifecycleWaitingEvent) {
try {
await componentsController.confirm({
message: 'An update to Ocula has been installed. Would you like to reload now and complete the update?',
confirmLabel: 'Yes, update',
cancelLabel: 'Later'
});
workbox.addEventListener('controlling', () => {
clearData();
window.location.reload();
});
if (registration && registration.waiting) {
messageSW(registration.waiting, {
type: 'SKIP_WAITING'
});
}
} catch {
// do nothing
}
}
workbox.addEventListener('waiting', handleUpdate);
workbox.addEventListener('externalwaiting', handleUpdate);
registration = await workbox.register();
}
================================================
FILE: client/src/static/.well-known/somthing.txt
================================================
sdfgdsf
================================================
FILE: client/src/static/robots.txt
================================================
Sitemap: https://app.ocula.io/sitemap.xml
================================================
FILE: client/src/static/sitemap.xml
================================================
https://app.ocula.io
1.0
https://app.ocula.io/maps
0.8
================================================
FILE: client/src/store/actions/add-location.ts
================================================
import updateSettings from './update-settings';
import setLocation from './set-location';
import type {
ILocation
} from '../../types/location';
import {
state
} from '../store';
import {
arrayUniqueBy
} from '@ocula/utilities';
export default function addLocation(location: ILocation, setAsCurrent: boolean = false): void {
const {
locations
} = state.settings;
updateSettings({
locations: arrayUniqueBy([...locations, location], ({ id }) => id)
});
if (setAsCurrent) {
setLocation(location);
}
}
================================================
FILE: client/src/store/actions/load-forecast.ts
================================================
import {
state,
mutate
} from '../store';
import {
getForecast
} from '../../services/weather';
export default async function loadForecast(latitude: number, longitude: number) {
const {
units
} = state.settings;
const forecast = await getForecast(latitude, longitude, units);
mutate('set-forecast', state => state.forecast = forecast);
}
================================================
FILE: client/src/store/actions/load-location.ts
================================================
import LOCATION from '../../enums/forecast/location';
import {
state,
mutate
} from '../store';
import {
getPosition
} from '../helpers/location';
import {
getLocation
} from '../../services/location';
import type {
ILocation
} from '../../types/location';
export default async function loadLocation(): Promise {
const {
location: savedLocation
} = state.settings;
let location = savedLocation;
if (location === LOCATION.current) {
const {
latitude,
longitude
} = await getPosition();
if (!latitude || !longitude) {
return;
}
location = await getLocation(latitude, longitude);
}
mutate('set-location', state => state.location = location as ILocation);
return location;
}
================================================
FILE: client/src/store/actions/move-section.ts
================================================
import FORECAST_SECTION from '../../enums/forecast/section';
import updateSettings from './update-settings';
import {
state
} from '../store';
import {
arraySwapBy
} from '@ocula/utilities';
export default function moveSection(type: FORECAST_SECTION, offset: number = 1): void {
let {
sections
} = state.settings.forecast;
const index = sections.findIndex(section => section.type === type);
sections = arraySwapBy(sections, index, index + offset);
updateSettings({
forecast: {
sections
}
});
}
================================================
FILE: client/src/store/actions/remove-location.ts
================================================
import updateSettings from './update-settings';
import type {
ILocation
} from '../../types/location';
import {
state
} from '../store';
export default function removeLocation(location: ILocation): void {
const {
locations
} = state.settings;
updateSettings({
locations: locations.filter(({ id }) => id !== location.id)
});
}
================================================
FILE: client/src/store/actions/reset-settings.ts
================================================
import SETTINGS from '../../constants/core/settings';
import {
mutate
} from '../store';
import {
saveSettings,
clearData
} from '../helpers/storage';
export default function resetSettings() {
mutate('reset-settings', state => state.settings = SETTINGS);
saveSettings(SETTINGS);
clearData();
}
================================================
FILE: client/src/store/actions/search-locations.ts
================================================
import {
searchLocations
} from '../../services/location';
import type {
ILocation
} from '../../types/location';
export default async function (query: string): Promise {
return searchLocations(query);
}
================================================
FILE: client/src/store/actions/set-current-location.ts
================================================
import LOCATION from '../../enums/forecast/location';
import setLocation from './set-location';
export default function setCurrentLocation(): void {
setLocation(LOCATION.current);
}
================================================
FILE: client/src/store/actions/set-location.ts
================================================
import LOCATION from '../../enums/forecast/location';
import EVENTS from '../../constants/core/events';
import setLastUpdated from '../mutations/set-last-updated';
import updateSettings from './update-settings';
import eventEmitter from '@ocula/event-emitter';
import {
typeIsNil
} from '@ocula/utilities';
import type {
ILocation
} from '../../types/location';
export default function setLocation(location: ILocation | LOCATION): void {
if (typeIsNil(location)) {
return;
}
setLastUpdated(null);
updateSettings({
location
});
eventEmitter.emit(EVENTS.location.set, location);
}
================================================
FILE: client/src/store/actions/set-section-visibility.ts
================================================
import FORECAST_SECTION from '../../enums/forecast/section';
import updateSettings from './update-settings';
import {
state
} from '../store';
export default function setSectionVisibility(type: FORECAST_SECTION, isVisible = true): void {
let {
sections
} = state.settings.forecast;
sections = sections.map(section => {
const visible = section.type === type ? isVisible : section.visible;
return {
...section,
visible
};
});
updateSettings({
forecast: {
sections
}
});
}
================================================
FILE: client/src/store/actions/update-settings.ts
================================================
import setSettings from '../mutations/set-settings';
import type {
ISettings
} from '../../types/storage';
export default function updateSettings(settings: Partial): void {
setSettings(settings);
}
================================================
FILE: client/src/store/actions/update.ts
================================================
import STATUS from '../../enums/core/status';
import GLOBAL from '../../constants/core/global';
import setStatus from '../mutations/set-status';
import setLastUpdated from '../mutations/set-last-updated';
import loadLocation from './load-location';
import loadForecast from './load-forecast';
import {
state
} from '../store';
import {
saveData
} from '../helpers/storage';
export default async function update(force: boolean = false) {
const lastUpdated = state.lastUpdated;
if (!force && lastUpdated && Date.now() - +lastUpdated < GLOBAL.updateThreshold) {
return;
}
setStatus(STATUS.loading);
try {
const {
latitude,
longitude
} = await loadLocation();
await loadForecast(latitude, longitude);
setLastUpdated();
const {
lastUpdated,
location,
forecast
} = state;
saveData({
lastUpdated,
location,
forecast
});
} catch (error) {
setStatus(STATUS.error);
}
setStatus(null);
}
================================================
FILE: client/src/store/getters/forecast.ts
================================================
import UNITS from '../../enums/forecast/units';
import FORMATS from '../../constants/forecast/formats';
import {
defaultFormatter
} from '../../constants/forecast/formatters';
import {
getter
} from '../store';
import {
objectTransform
} from '@ocula/utilities';
import type {
Formatted,
IMappedForecast
} from '../../types/state';
import type {
IForecast
} from '../../types/weather';
export default getter>(state => {
const {
forecast,
settings
} = state;
if (!forecast) {
return;
}
const format = FORMATS[settings.units] || FORMATS[UNITS.metric];
const {
daily,
...other
} = objectTransform>(forecast, format, defaultFormatter);
const today = daily.shift();
return {
...other,
today,
daily
};
});
================================================
FILE: client/src/store/getters/format.ts
================================================
import {
getter
} from '../store';
import {
dateFormat,
dateUtcToZoned
} from '@ocula/utilities';
import type {
IFormatter
} from '../../types/state';
export default getter(({ forecast }) => {
let options;
let converter = value => value;
const output = {
date: (value: Date, format: string = 'EEEE, d MMM') => dateFormat(converter(value), format, options),
time: (value: Date, format: string = 'h:mm a') => dateFormat(converter(value), format, options).toLowerCase()
};
if (forecast && forecast.timezone) {
converter = value => dateUtcToZoned(value, forecast.timezone);
options = {
timeZone: forecast.timezone
};
}
return output;
});
================================================
FILE: client/src/store/getters/phase.ts
================================================
import getPhase from '../../helpers/get-phase';
import {
getter
} from '../store';
import {
dateToUnix
} from '@ocula/utilities';
export default getter(() => getPhase(dateToUnix(new Date())));
================================================
FILE: client/src/store/getters/theme.ts
================================================
import THEME from '../../constants/forecast/theme';
import phase from './phase';
import {
getter
} from '../store';
import {
core,
weather
} from '../../themes';
import type {
ITheme
} from '../../types/themes';
import {
typeIsPlainObject
} from '@ocula/utilities';
function getPhasedTheme(theme: ITheme): ITheme {
let {
colour,
mapStyle,
...value
} = theme;
if (typeIsPlainObject(colour)) {
colour = colour[phase.value]
}
if (typeIsPlainObject(mapStyle)) {
mapStyle = mapStyle[phase.value];
}
return {
...value,
colour,
mapStyle
};
}
export default getter(({ settings, forecast }) => {
const {
theme
} = settings;
let coreTheme = core[theme] || core.default;
let weatherTheme = weather.default;
coreTheme = getPhasedTheme(coreTheme);
if (forecast && forecast.current) {
const conditionId = forecast.current.weather[0].id;
weatherTheme = THEME[conditionId] || THEME[Math.floor(conditionId / 100) * 100] || weather.default;
weatherTheme = getPhasedTheme(weatherTheme);
}
return {
core: coreTheme,
weather: weatherTheme
};
});
================================================
FILE: client/src/store/getters/unit-of-measure.ts
================================================
import UNITS from '../../enums/forecast/units';
import UNIT_OF_MEASURE from '../../constants/forecast/unit-of-measure';
import {
getter
} from '../store';
export default getter(({ settings }) => UNIT_OF_MEASURE[settings.units || UNITS.metric])
================================================
FILE: client/src/store/helpers/location.ts
================================================
import type {
ICoordinate
} from '../../types/location';
export async function getPosition(): Promise {
const position: Position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
maximumAge: 0,
enableHighAccuracy: true
});
});
if (position) {
return position.coords;
}
return {
latitude: 0,
longitude: 0,
};
}
================================================
FILE: client/src/store/helpers/storage.ts
================================================
import DATA from '../../constants/core/data';
import SETTINGS from '../../constants/core/settings';
import MIGRATIONS from '../../constants/core/migrations';
import STORAGE_KEYS from '../../constants/core/storage-keys';
import {
objectMerge, objectMergeWith, typeIsArray
} from '@ocula/utilities';
import type {
ISettings,
IStoredData
} from '../../types/storage';
export function getSettings(): ISettings {
const storedSettings = localStorage.getItem(STORAGE_KEYS.settings);
if (!storedSettings) {
return SETTINGS;
}
let settings = JSON.parse(storedSettings) as ISettings;
settings = objectMergeWith(SETTINGS, settings, (obj, src) => {
if (typeIsArray(obj)) {
return src;
}
});
if (settings.version < SETTINGS.version && settings.version in MIGRATIONS) {
try {
settings = MIGRATIONS[settings.version.toString()](settings);
settings.version = SETTINGS.version;
saveSettings(settings);
} catch (error) {
console.warn('Failed to migrate settings');
}
}
return settings;
}
export function getData(): IStoredData {
const storedData = localStorage.getItem(STORAGE_KEYS.data);
if (!storedData) {
return DATA;
}
const data = JSON.parse(storedData) as IStoredData;
data.lastUpdated = new Date(data.lastUpdated);
return objectMerge(DATA, data);
}
export function saveSettings(settings: ISettings): void {
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings));
}
export function saveData({ lastUpdated, location, forecast }: IStoredData): void {
localStorage.setItem(STORAGE_KEYS.data, JSON.stringify({
location,
forecast,
lastUpdated
}));
}
export function clearData(): void {
localStorage.removeItem(STORAGE_KEYS.data);
}
================================================
FILE: client/src/store/index.ts
================================================
export { state } from './store';
export { default as forecast } from './getters/forecast';
export { default as phase } from './getters/phase';
export { default as format } from './getters/format';
export { default as theme } from './getters/theme';
export { default as unitOfMeasure } from './getters/unit-of-measure';
export { default as update } from './actions/update';
export { default as loadLocation } from './actions/load-location';
export { default as loadForecast } from './actions/load-forecast';
export { default as searchLocations } from './actions/search-locations';
export { default as addLocation } from './actions/add-location';
export { default as removeLocation } from './actions/remove-location';
export { default as setLocation } from './actions/set-location';
export { default as setCurrentLocation } from './actions/set-current-location';
export { default as moveSection } from './actions/move-section';
export { default as setSectionVisibility } from './actions/set-section-visibility';
export { default as updateSettings } from './actions/update-settings';
export { default as resetSettings } from './actions/reset-settings';
================================================
FILE: client/src/store/mutations/set-last-updated.ts
================================================
import {
mutate
} from '../store';
export default function setLastUpdated(value: Date | null = new Date()): void {
mutate('set-last-updated', state => state.lastUpdated = value);
}
================================================
FILE: client/src/store/mutations/set-settings.ts
================================================
import {
state,
mutate
} from '../store';
import {
saveSettings
} from '../helpers/storage';
import type {
ISettings
} from '../../types/storage';
export default function setSettings(value: Partial) {
const settings = {
...state.settings,
...value
};
mutate('set-settings', state => state.settings = settings);
saveSettings(settings);
}
================================================
FILE: client/src/store/mutations/set-status.ts
================================================
import STATUS from '../../enums/core/status';
import {
mutate
} from '../store';
export default function setStatus(status: STATUS = null): void {
mutate('set-status', state => state.status = status);
}
================================================
FILE: client/src/store/state/index.ts
================================================
import {
getSettings,
getData
} from '../helpers/storage';
import {
IState
} from '../../types/state';
function getState(): IState {
const settings = getSettings();
const {
location,
forecast,
lastUpdated
} = getData();
return {
settings,
location,
forecast,
lastUpdated,
status: null
};
}
export default getState();
================================================
FILE: client/src/store/store.ts
================================================
import createStore from '@ocula/state';
import _state from './state';
export const {
state,
getter,
mutate
} = createStore('ocula', _state);
================================================
FILE: client/src/themes/core/dark/_dark.scss
================================================
@mixin dark {
--font__colour: #F7F7F7;
--background__colour: #333333;
--background__colour--hover: #444444;
--border__colour: #777777;
}
================================================
FILE: client/src/themes/core/dark/index.scss
================================================
@import "./_dark";
.theme--dark {
@include dark;
}
================================================
FILE: client/src/themes/core/dark/index.ts
================================================
import './index.scss';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'dark',
name: 'Dark',
colour: '#333333',
class: 'theme--dark',
mapStyle: 'dark'
} as ITheme;
================================================
FILE: client/src/themes/core/default/index.scss
================================================
@import "../light/_light";
@import "../dark/_dark";
.theme--default {
@include light;
}
.phase--night {
&.theme--default {
@include dark;
}
}
@media (prefers-color-scheme: dark) {
.theme--default {
@include dark;
}
}
================================================
FILE: client/src/themes/core/default/index.ts
================================================
import './index.scss';
import PHASE from '../../../enums/forecast/phase';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'default',
name: 'Default',
colour: {
[PHASE.night as string]: '#333333'
},
class: 'theme--default',
mapStyle: {
[PHASE.night as string]: 'dark'
}
} as ITheme;
================================================
FILE: client/src/themes/core/index.ts
================================================
import _default from './default';
import light from './light';
import dark from './dark';
export default {
default: _default,
light,
dark
};
================================================
FILE: client/src/themes/core/light/_light.scss
================================================
@mixin light {
--font__colour: #353539;
--background__colour: #FFFFFF;
--background__colour--hover: #EEEEEE;
--border__colour: #EDEDED;
}
================================================
FILE: client/src/themes/core/light/index.scss
================================================
@import "./_light";
.theme--light {
@include light;
}
================================================
FILE: client/src/themes/core/light/index.ts
================================================
import './index.scss';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'light',
name: 'Light',
colour: '#FFFFFF',
class: 'theme--light',
mapStyle: 'light'
} as ITheme;
================================================
FILE: client/src/themes/index.ts
================================================
export { default as core } from './core';
export { default as weather } from './weather';
================================================
FILE: client/src/themes/weather/clear/index.scss
================================================
.theme--weather-clear {
--font__colour--weather: #FFFFFF;
--background__colour--weather: #5D9BE5;
}
.phase--night {
& .theme--weather-clear {
--background__colour--weather: #44296A;
}
}
================================================
FILE: client/src/themes/weather/clear/index.ts
================================================
import './index.scss';
import PHASE from '../../../enums/forecast/phase';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'weather-clear',
name: 'Clear',
colour: {
[PHASE.day as string]: '#5D9BE5',
[PHASE.night as string]: '#44296A'
},
class: 'theme--weather-clear',
mapStyle: {
[PHASE.night as string]: 'dark'
}
} as ITheme;
================================================
FILE: client/src/themes/weather/default/index.ts
================================================
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'weather-default',
name: 'Default',
colour: '',
class: ''
} as ITheme;
================================================
FILE: client/src/themes/weather/index.ts
================================================
import _default from './default';
import clear from './clear';
import partlyCloudy from './partly-cloudy';
import rainy from './rainy';
export default {
default: _default,
clear,
partlyCloudy,
rainy
};
================================================
FILE: client/src/themes/weather/partly-cloudy/index.scss
================================================
.theme--weather-partly-cloudy {
--font__colour--weather: #FFFFFF;
--background__colour--weather: #5D9BE5;
}
.phase--night {
& .theme--weather-partly-cloudy {
--background__colour--weather: #44296A;
}
}
================================================
FILE: client/src/themes/weather/partly-cloudy/index.ts
================================================
import './index.scss';
import PHASE from '../../../enums/forecast/phase';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'weather-partly-cloudy',
name: 'Partly Cloudy',
colour: {
[PHASE.day as string]: '#5D9BE5',
[PHASE.night as string]: '#44296A'
},
class: 'theme--weather-partly-cloudy',
mapStyle: {
[PHASE.night as string]: 'dark'
}
} as ITheme;
================================================
FILE: client/src/themes/weather/rainy/index.scss
================================================
.theme--weather-rainy {
--font__colour--weather: #FFFFFF;
--background__colour--weather: #AAAAAA;
}
================================================
FILE: client/src/themes/weather/rainy/index.ts
================================================
import './index.scss';
import type {
ITheme
} from '../../../types/themes';
export default {
id: 'weather-rainy',
name: 'Rainy',
colour: '#AAAAAA',
class: 'theme--weather-rainy'
} as ITheme;
================================================
FILE: client/src/types/location.ts
================================================
export interface ICoordinate {
latitude: number;
longitude: number;
}
export interface ILocation extends ICoordinate {
id: string;
shortName: string;
longName: string;
}
================================================
FILE: client/src/types/state.ts
================================================
import type {
ISettings
} from './storage';
import type {
ILocation
} from './location';
import type {
IForecast,
IForecastCurrent,
IForecastWeather,
IForecastDay,
IForecastHour
} from './weather';
import STATUS from '../enums/core/status';
export type Formatted = {
[P in keyof T]: T[P] extends string | number ? {
raw: T[P];
formatted: U
} : Formatted
}
export interface IState {
status: STATUS;
lastUpdated: Date;
settings: ISettings;
location: ILocation;
forecast: IForecast;
};
export interface IMappedForecastCurrent extends Omit {
weather: IForecastWeather
};
export interface IMappedForecastDay extends Omit {
weather: IForecastWeather
};
export interface IMappedForecastHour extends Omit {
weather: IForecastWeather
};
export interface IMappedForecast extends Omit {
current: IMappedForecastCurrent;
today: IMappedForecastDay;
daily: IMappedForecastDay[];
hourly: IMappedForecastHour[];
};
export interface IFormatter {
date(value: Date, format?: string): string;
time(value: Date, format?: string): string;
};
================================================
FILE: client/src/types/storage.ts
================================================
import type LOCATION from '../enums/forecast/location';
import UNITS from '../enums/forecast/units';
import type MAP from '../enums/maps/map';
import type FORECAST_SECTION from '../enums/forecast/section';
import type {
ILocation
} from './location';
import type {
IForecast
} from './weather';
interface ISection {
type: FORECAST_SECTION;
visible: boolean;
options: any;
}
export interface IForecastSettings {
sections: ISection[];
}
export interface IMapSettings {
default: MAP;
zoom: number;
pitch: number;
framerate: number;
}
export interface ISettings {
version: number;
units: UNITS;
theme: string;
location?: ILocation | LOCATION;
locations?: ILocation[];
forecast: IForecastSettings;
maps: IMapSettings;
};
export interface IStoredData {
lastUpdated: Date;
location: ILocation;
forecast: IForecast;
}
================================================
FILE: client/src/types/themes.ts
================================================
export interface ITheme {
id: string;
name: string;
colour: string | Record;
class: string | string[];
mapStyle: string | Record;
}
================================================
FILE: client/src/types/weather.ts
================================================
export interface IForecast {
lat: number;
lon: number;
timezone: string;
timezoneOffset: number;
current: IForecastCurrent;
hourly: IForecastHour[];
daily: IForecastDay[];
tides: IForecastTides;
radar: IForecastRadar;
}
export interface IForecastWeather {
id: number;
main: string;
description: string;
icon: string;
}
export interface IForecastFeelsLike {
day: number;
night: number;
eve: number;
morn: number;
}
export interface IForecastTemperature {
day: number;
min: number;
max: number;
night: number;
eve: number;
morn: number;
}
export interface IForecastCurrent {
dt: number;
sunrise?: number;
sunset?: number;
temp: number;
feelsLike: number;
pressure: number;
humidity: number;
dewPoint: number;
uvi?: number;
clouds: number;
visibility: number;
windSpeed: number;
windDeg: number;
weather: IForecastWeather[];
rain?: Record;
snow?: Record;
}
export interface IForecastHour {
dt: number;
sunrise?: number;
sunset?: number;
temp: number;
feelsLike: number;
pressure: number;
humidity: number;
dewPoint: number;
uvi?: number;
clouds: number;
visibility: number;
windSpeed: number;
windDeg: number;
weather: IForecastWeather[];
pop: number;
rain?: Record;
snow?: Record;
}
export interface IForecastDay {
dt: number;
sunrise: number;
sunset: number;
temp: IForecastTemperature;
feelsLike: IForecastFeelsLike;
pressure: number;
humidity: number;
dewPoint: number;
windSpeed: number;
windDeg: number;
weather: IForecastWeather[];
clouds: number;
pop: number;
uvi: number;
rain?: Record;
snow?: Record;
}
export interface IForecastTideHeight {
dt: number;
date: string;
height: number;
}
export interface IForecastTideExtreme extends IForecastTideHeight {
type?: string;
}
export interface IForecastTides {
status: number;
callCount: number;
copyright: string;
requestLat: number;
requestLon: number;
responseLat: number;
responseLon: number;
atlas: string;
station: string;
heights: IForecastTideHeight[];
extremes: IForecastTideExtreme[];
}
export interface IForecastRadar {
timestamps: number[];
}
================================================
FILE: client/src/vue-shim.d.ts
================================================
declare module "*.vue" {
import Vue from 'vue'
export default Vue
}
================================================
FILE: client/tsconfig.json
================================================
{
"extends": "../tsconfig.json",
"compilerOptions": {
"resolveJsonModule": true
}
}
================================================
FILE: client/webpack.config.babel.js
================================================
module.exports = env => require(`./build/${env}`);
================================================
FILE: package.json
================================================
{
"name": "@ocula/app",
"repository": "https://github.com/andrewcourtice/ocula.git",
"author": "Andrew Courtice",
"description": "The free and open-source progressive weather app",
"license": "MIT",
"private": true,
"workspaces": [
"api",
"client",
"packages/*"
],
"scripts": {
"dev": "cd client && yarn start --port $PORT",
"build": "cd client && yarn build"
},
"devDependencies": {
"typescript": "^4.1.2"
}
}
================================================
FILE: packages/charts/package.json
================================================
{
"name": "@ocula/charts",
"version": "1.0.0",
"main": "./src/index.ts",
"license": "MIT",
"dependencies": {
"@ocula/utilities": "1.0.0",
"d3-array": "^2.9.1",
"d3-axis": "^2.0.0",
"d3-ease": "^2.0.0",
"d3-path": "^2.0.0",
"d3-scale": "^3.2.3",
"d3-selection": "^2.0.0",
"d3-shape": "^2.0.0",
"d3-transition": "^2.0.0"
}
}
================================================
FILE: packages/charts/src/charts/_base/chart.ts
================================================
import * as d3 from '../../d3';
import {
objectMerge,
stringUniqueId
} from '@ocula/utilities';
export interface IChartOptions {
padding?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
},
classes?: {
svg?: string;
canvas?: string;
},
animation?: {
duration?: number;
}
}
export default abstract class Chart {
protected id: string;
protected element: Element;
protected options: T;
protected rendering: boolean;
protected width: number;
protected height: number;
protected svg: d3.Selection;
protected canvas: d3.Selection;
constructor(element: Element) {
this.id = stringUniqueId();
this.element = element;
this.width = 0;
this.height = 0;
this.svg = d3.select(this.element)
.append('svg')
.attr('id', `chart-${this.id}`)
.attr('width', '100%')
.attr('height', '100%')
.style('display', 'block');
this.canvas = this.svg.append('g');
}
protected get defaultOptions(): IChartOptions {
return {
classes: {
svg: 'chart',
canvas: 'chart__canvas'
},
padding: {
top: 10,
bottom: 10,
left: 10,
right: 10
},
animation: {
duration: 1000
}
};
}
protected bootstrap(options: T) {
this.options = objectMerge(this.defaultOptions, options);
const {
width,
height
} = this.element.getBoundingClientRect();
const {
top,
left,
bottom,
right
} = this.options.padding;
this.width = width - (left + right);
this.height = height - (top + bottom);
this.svg.classed(this.options.classes.svg, true)
.attr('viewBox', `0 0 ${width} ${height}`);
this.canvas.classed(this.options.classes.canvas, true)
.attr('transform', `translate(${left}, ${top})`);
}
protected reset() {
this.canvas.selectAll('*').remove();
}
}
================================================
FILE: packages/charts/src/charts/line/constants/curve.ts
================================================
import LINE_TYPE from '../enums/line-type';
import * as d3 from '../../../d3';
export default {
[LINE_TYPE.line]: d3.curveLinear,
[LINE_TYPE.spline]: d3.curveCatmullRom.alpha(1),
[LINE_TYPE.step]: d3.curveStep
};
================================================
FILE: packages/charts/src/charts/line/enums/line-type.ts
================================================
const enum LINE_TYPE {
line = 'line',
spline = 'spline',
step = 'step'
};
export default LINE_TYPE;
================================================
FILE: packages/charts/src/charts/line/enums/marker-type.ts
================================================
const enum MARKER_TYPE {
point = 'point',
arrow = 'arrow'
};
export default MARKER_TYPE;
================================================
FILE: packages/charts/src/charts/line/index.ts
================================================
import LINE_TYPE from './enums/line-type';
import MARKER_TYPE from './enums/marker-type';
import SCALE from '../../enums/scale';
import CURVE from './constants/curve';
import Chart from '../_base/chart';
import * as d3 from '../../d3';
import {
getScale
} from '../../scales';
import {
valueGetAccessor
} from '@ocula/utilities';
import type {
ILineOptions,
ILinePoint
} from './types';
const lineGenerator = d3.line()
.defined(data => !!data.y1)
.x(data => data.x)
.y(data => data.y1);
const areaGenerator = d3.area()
.defined(data => !!data.y1)
.x(data => data.x)
.y0(data => data.y0)
.y1(data => data.y1);
export default class LineChart extends Chart {
private lineGroup: d3.Selection;
private markerGroup: d3.Selection;
private axisGroup: d3.Selection;
private xAxis: d3.Selection;
private yAxis: d3.Selection;
constructor(element: Element) {
super(element);
this.axisGroup = this.canvas.append('g');
this.lineGroup = this.canvas.append('g');
this.markerGroup = this.canvas.append('g');
this.xAxis = this.axisGroup.append('g');
this.yAxis = this.axisGroup.append('g');
}
protected get defaultOptions(): ILineOptions {
return {
...super.defaultOptions,
type: LINE_TYPE.line,
scales: {
x: {
type: SCALE.point,
value: item => item.x,
format: value => value
},
y: {
type: SCALE.linear,
value: item => item.y,
format: value => value
}
},
markers: {
visible: true,
type: MARKER_TYPE.point
},
labels: {
visible: true,
content: value => value.yValue
},
padding: {
top: 32,
bottom: 32,
left: 0,
right: 0
},
classes: {
svg: 'spline-chart__svg',
canvas: 'spline-chart__canvas',
lineGroup: 'spline-chart__line-group',
markerGroup: 'spline-chart__marker-group',
line: 'spline-chart__line',
area: 'spline-chart__area',
marker: 'spline-chart__marker'
},
colours: {
line: '#000000',
marker: '#000000',
axis: '#000000',
tick: '#000000',
label: '#000000'
}
};
}
protected bootstrap(options: ILineOptions) {
super.bootstrap(options);
const {
classes,
padding
} = this.options;
this.lineGroup.classed(classes.lineGroup, true);
this.markerGroup.classed(classes.markerGroup, true);
this.xAxis.attr('transform', `translate(0, ${padding.top + this.height})`);
}
protected reset() {
this.lineGroup.selectAll('*').remove();
this.markerGroup.selectAll('*').remove();
}
private async drawMarkers() {
const {
colours,
labels,
animation
} = this.options;
const getLabel = valueGetAccessor(labels.content);
const updates = this.markerGroup.selectAll('g')
.data(data => data, item => item.xValue);
const entries = updates.enter()
.append('g')
.attr('transform', data => `translate(${data.x}, ${data.y1})`);
updates.exit().remove();
entries.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('fill', 'var(--font__colour)')
.style('font-size', 'var(--font__size--small)');
entries.append('circle')
.attr('r', 3);
const merges = updates.merge(entries);
merges.select('circle')
.attr('fill', colours.marker);
merges.select('text')
.text((data, index) => getLabel(data, index))
.attr('dy', data => Math.sign(data.yValue) * -16);
return updates.transition()
.duration(animation.duration)
.ease(d3.easePolyOut.exponent(4))
.attr('transform', data => `translate(${data.x}, ${data.y1})`);
}
private async drawArea() {
const {
type,
classes,
colours,
animation
} = this.options;
const curve = CURVE[type] || CURVE.line;
let area = this.lineGroup.select(`.${classes.area}`);
if (area.empty()) {
area = this.lineGroup.append('path')
.classed(classes.area, true)
.attr('fill', colours.line)
.attr('fill-opacity', 0.5)
.attr('d', areaGenerator.curve(curve));
}
return area.transition()
.duration(animation.duration)
.ease(d3.easePolyOut.exponent(4))
.attr('d', areaGenerator.curve(curve))
.attr('fill', colours.line)
.end();
}
private async drawLine() {
const {
type,
classes,
colours,
animation
} = this.options;
const curve = CURVE[type] || CURVE.line;
let line = this.lineGroup.select(`.${classes.line}`);
if (line.empty()) {
line = this.lineGroup.append('path')
.classed(classes.line, true)
.attr('stroke-width', 2)
.attr('stroke', colours.line)
.attr('fill', 'none')
.attr('d', lineGenerator.curve(curve));
}
return line.transition()
.duration(animation.duration)
.ease(d3.easePolyOut.exponent(4))
.attr('d', lineGenerator.curve(curve))
.attr('stroke', colours.line)
.end();
}
private drawAxes() {
const {
colours
} = this.options;
const {
xAxis,
yAxis
} = this.axisGroup.datum();
function styleAxis(group, axis) {
group.call(axis);
if (colours.axis === false) {
group.select('.domain').remove();
}
group.select('.domain')
.attr('stroke', colours.axis);
group.selectAll('.tick line')
.attr('stroke', colours.tick);
group.selectAll('.tick text')
.attr('fill', colours.label);
}
styleAxis(this.xAxis, xAxis);
styleAxis(this.yAxis, yAxis);
const yScale = yAxis.scale();
const yMin = yScale.domain()[0];
const translation = yScale(yMin);
this.xAxis.attr('transform', `translate(0, ${translation})`);
}
private async draw() {
return Promise.all([
this.drawArea(),
this.drawLine(),
this.drawMarkers()
]);
}
private getAxis(axisType, scale, options) {
const {
ticks,
format,
size
} = options;
const axis = axisType(scale)
.tickFormat(format);
if (ticks && typeof ticks === 'number') {
axis.ticks(ticks);
}
if (ticks && typeof ticks === 'function') {
axis.tickValues(ticks(scale.domain()));
}
if (size > 0) {
axis.tickSize(size);
}
return axis;
}
private calculate(data: T[]) {
const {
x: xOptions,
y: yOptions
} = this.options.scales;
const xScale = getScale(data, xOptions, [0, this.width]);
const yScale = getScale(data, yOptions, [this.height - 2, 2]);
const xAxis = this.getAxis(d3.axisBottom, xScale, xOptions);
const yAxis = this.getAxis(d3.axisRight, yScale, yOptions);
const points = data.map(value => {
const xValue = xOptions.value(value);
const yValue = yOptions.value(value);
return {
value,
xValue,
yValue,
x: xScale(xValue),
y0: yScale(0),
y1: yScale(yValue)
};
});
this.axisGroup.datum({
xAxis,
yAxis
});
this.lineGroup.datum(points);
this.markerGroup.datum(points);
}
public async render(data: T[], options: ILineOptions) {
if (this.rendering) {
this.reset();
}
this.rendering = true;
this.bootstrap(options);
this.calculate(data);
try {
await this.draw();
} finally {
this.rendering = false;
}
}
}
================================================
FILE: packages/charts/src/charts/line/types/index.ts
================================================
import LINE_TYPE from '../enums/line-type';
import MARKER_TYPE from '../enums/marker-type';
import SCALE from '../../../enums/scale';
import type {
IChartOptions
} from '../../_base/chart';
export interface ILinePoint {
value: T;
xValue: number;
yValue: number;
x: number;
y0: number;
y1: number;
};
export interface ILineScaleOptions {
type?: SCALE,
value?(value: T, index?: number): any;
format?(value: any): string;
}
export interface ILineOptions extends IChartOptions {
type?: LINE_TYPE,
scales: {
x?: ILineScaleOptions;
y?: ILineScaleOptions;
},
markers?: {
type?: MARKER_TYPE;
visible?: boolean;
},
labels?: {
visible?: boolean;
content?(value: ILinePoint, index?: number): any;
},
classes?: {
svg?: string;
canvas?: string;
lineGroup?: string;
markerGroup?: string;
line?: string;
area?: string;
marker?: string;
},
colours?: {
line?: string;
marker?: string;
axis?: string;
tick?: string;
label?: string;
}
}
================================================
FILE: packages/charts/src/d3/index.ts
================================================
export * from 'd3-array';
export * from 'd3-axis';
export * from 'd3-ease';
export * from 'd3-path';
export * from 'd3-scale';
export * from 'd3-selection';
export * from 'd3-shape';
export * from 'd3-transition';
================================================
FILE: packages/charts/src/enums/scale.ts
================================================
const enum SCALE {
linear = 'linear',
point = 'point',
time = 'time'
};
export default SCALE;
================================================
FILE: packages/charts/src/index.ts
================================================
export { default as SCALE_TYPE } from './enums/scale';
export { default as LINE_TYPE } from './charts/line/enums/line-type';
export { default as MARKER_TYPE } from './charts/line/enums/marker-type';
export { default as LineChart } from './charts/line';
export * from './charts/line/types';
================================================
FILE: packages/charts/src/scales/index.ts
================================================
import SCALE from '../enums/scale';
import linear from './linear';
import point from './point';
import time from './time';
export const SCALES = {
[SCALE.linear]: linear,
[SCALE.point]: point,
[SCALE.time]: time
};
export function getScale(data, options, range) {
const {
type
} = options;
return (SCALES[type] || SCALES[SCALE.linear])(data, options, range);
}
export default SCALES;
================================================
FILE: packages/charts/src/scales/linear.ts
================================================
import * as d3 from '../d3';
export default function(data, options, range) {
const {
value
} = options;
const extent = d3.extent(data, value);
const domain = [Math.min(extent[0], 0), Math.max(extent[1], 0)];
return d3.scaleLinear()
.domain(domain)
.range(range)
.nice();
}
================================================
FILE: packages/charts/src/scales/point.ts
================================================
import * as d3 from '../d3';
export default function(data, options, range) {
const {
value
} = options;
const domain = data.map(value);
return d3.scalePoint()
.domain(domain)
.range(range);
}
================================================
FILE: packages/charts/src/scales/time.ts
================================================
import * as d3 from '../d3';
export default function(data, options, range) {
const {
value
} = options;
const domain = d3.extent(data, value);
return d3.scaleTime()
.domain(domain)
.range(range);
}
================================================
FILE: packages/components/package.json
================================================
{
"name": "@ocula/components",
"version": "1.0.0",
"main": "./src/index.ts",
"license": "MIT",
"dependencies": {
"@ocula/event-emitter": "1.0.0",
"@ocula/style": "1.0.0",
"@ocula/task-queue": "1.0.0",
"@ocula/utilities": "1.0.0",
"mapbox-gl": "^2.0.0"
},
"peerDependencies": {
"vue": "3.0.5"
},
"devDependencies": {
"@types/mapbox-gl": "^1.12.9"
}
}
================================================
FILE: packages/components/src/components/accordion/accordion-pane.vue
================================================
================================================
FILE: packages/components/src/components/accordion/accordion.vue
================================================
================================================
FILE: packages/components/src/components/accordion/constants/events.ts
================================================
export default {
openPane: 'open-pane',
closePane: 'close-pane',
togglePane: 'toggle-pane',
paneOpened: 'pane-opened',
paneClosed: 'pane-closed',
closeAll: 'close-all-panes',
closeExcept: 'close-all-panes-except'
} as const;
================================================
FILE: packages/components/src/components/block/block.vue
================================================
================================================
FILE: packages/components/src/components/container/container.vue
================================================
================================================
FILE: packages/components/src/components/core/confirm-modal.vue
================================================
{{ message }}
{{ cancelLabel }}
{{ confirmLabel }}
================================================
FILE: packages/components/src/components/core/index.vue
================================================
================================================
FILE: packages/components/src/components/drawer/drawer.vue
================================================
================================================
FILE: packages/components/src/components/icon/icon.vue
================================================
================================================
FILE: packages/components/src/components/icon-button/icon-button.vue
================================================
================================================
FILE: packages/components/src/components/icon-label/icon-label.vue
================================================
================================================
FILE: packages/components/src/components/index.ts
================================================
import Accordion from './accordion/accordion.vue';
import AccordionPane from './accordion/accordion-pane.vue';
import Block from './block/block.vue';
import Container from './container/container.vue';
import Drawer from './drawer/drawer.vue';
import Icon from './icon/icon.vue';
import IconButton from './icon-button/icon-button.vue';
import IconLabel from './icon-label/icon-label.vue';
import MapboxMap from './mapbox/mapbox-map.vue';
import MapboxLegend from './mapbox/mapbox-legend.vue';
import MapboxRasterLayer from './mapbox/mapbox-raster-layer.vue';
import Layout from './layout/layout.vue';
import Loader from './loader/loader.vue';
import Modal from './modal/modal.vue';
import SearchBox from './search-box/search-box.vue';
import TransitionBoxResize from './transitions/box-resize.vue';
import CoreComponents from './core/index.vue';
export default {
Accordion,
AccordionPane,
Block,
Container,
Drawer,
Icon,
IconButton,
IconLabel,
MapboxMap,
MapboxLegend,
MapboxRasterLayer,
Layout,
Loader,
Modal,
SearchBox,
TransitionBoxResize,
CoreComponents
};
================================================
FILE: packages/components/src/components/layout/layout.vue
================================================
================================================
FILE: packages/components/src/components/loader/loader.vue
================================================
================================================
FILE: packages/components/src/components/mapbox/compositions/layer.ts
================================================
import {
inject,
watch,
onMounted,
onBeforeUnmount,
PropType
} from 'vue';
import {
stringUniqueId
} from '@ocula/utilities';
import type {
IInteractiveMap
} from '../types';
import type {
Layer,
Layout,
AnyPaint
} from 'mapbox-gl';
export const layerProps = {
id: {
type: String,
default: () => stringUniqueId()
},
minzoom: {
type: Number,
default: 0
},
maxzoom: {
type: Number,
default: 22
},
layout: {
type: Object as PropType
},
paint: {
type: Object as PropType
}
};
export function useLayer(props: any, layer: Layer) {
const map = inject('map');
watch(() => props.layout, value => map.updateLayout(layer.id, value));
watch(() => props.paint, value => map.updatePaint(layer.id, value));
onMounted(() => map.addLayer(layer));
onBeforeUnmount(() => map.removeLayer(layer));
return {
map
};
}
================================================
FILE: packages/components/src/components/mapbox/mapbox-legend.vue
================================================
================================================
FILE: packages/components/src/components/mapbox/mapbox-map.vue
================================================
================================================
FILE: packages/components/src/components/mapbox/mapbox-raster-layer.vue
================================================
================================================
FILE: packages/components/src/components/mapbox/types/index.ts
================================================
import type {
AnyPaint,
Layer,
Layout
} from 'mapbox-gl';
export interface IInteractiveMap {
addLayer(layer: Layer): void;
removeLayer(layer: Layer): void;
updateLayout(layerId: string, layout: Layout): void;
updatePaint(layerId: string, paint: AnyPaint): void;
}
================================================
FILE: packages/components/src/components/modal/modal.vue
================================================
================================================
FILE: packages/components/src/components/search-box/search-box.vue
================================================
================================================
FILE: packages/components/src/components/transitions/box-resize.vue
================================================
================================================
FILE: packages/components/src/compositions/layer.ts
================================================
import eventEmitter from '../event-emitter';
import {
ref,
onBeforeMount,
onUnmounted
} from 'vue';
import type {
IPromisePayload
} from '../types';
import type {
IListener
} from '@ocula/event-emitter';
import type {
SetupContext
} from '@vue/runtime-core';
export default function(id: string, { emit }: SetupContext) {
const isOpen = ref(false);
let promise: IPromisePayload;
function open(payload: any, promisePayload: IPromisePayload) {
emit('open', payload);
promise = promisePayload;
isOpen.value = true;
}
function close(payload: any) {
emit('close', payload);
if (promise) {
promise.resolve(payload);
promise = null;
}
isOpen.value = false;
}
function cancel(payload: any) {
emit('cancel', payload);
if (promise) {
promise.reject(payload);
promise = null;
}
isOpen.value = false;
}
let listeners = [] as IListener[];
onBeforeMount(() => {
listeners = [
eventEmitter.on(`open:${id}`, open),
eventEmitter.on(`close:${id}`, close),
];
});
onUnmounted(() => {
listeners.forEach(({ dispose }) => dispose());
if (promise) {
promise.reject();
}
});
return {
isOpen,
open,
close,
cancel
};
}
================================================
FILE: packages/components/src/compositions/subscriber.ts
================================================
import eventEmitter from '@ocula/event-emitter';
import {
onBeforeMount,
onUnmounted
} from 'vue';
export default function(event: string, callback: Function): void {
onBeforeMount(() => eventEmitter.on(event, callback));
onUnmounted(() => eventEmitter.off(event, callback));
}
================================================
FILE: packages/components/src/compositions/timer.ts
================================================
import {
onBeforeMount,
onUnmounted
} from 'vue';
type Timer = 'interval' | 'timeout';
interface ITimerApplication {
set(handler: Function, timeout: number, ...args: any[]): number;
clear(handle: number): void;
}
const TIMER = {
interval: {
set: (handler: Function, timeout: number, immediateInvoke: boolean = true) => {
if (immediateInvoke) {
handler();
}
return window.setInterval(handler, timeout);
},
clear: window.clearInterval
},
timeout: {
set: window.setTimeout,
clear: window.clearTimeout
}
} as Record
export default function useTimer(handler: Function, timeout: number, timer: Timer = 'interval'): number {
let handle;
const timerApplication = TIMER[timer];
onBeforeMount(() => handle = timerApplication.set.call(window, handler, timeout));
onUnmounted(() => timerApplication.clear.call(window, handle));
return handle
}
================================================
FILE: packages/components/src/constants/modals.ts
================================================
export default {
confirm: 'modals:confirm'
} as const;
================================================
FILE: packages/components/src/controllers/components.ts
================================================
import MODALS from '../constants/modals';
import eventEmitter from '../event-emitter';
import type {
IConfirmModalPayload
} from '../types';
class ComponentsController {
public async open(id: string, payload?: any): Promise {
return new Promise((resolve, reject) => {
eventEmitter.emit(`open:${id}`, payload, { resolve, reject });
});
}
public close(id: string, payload?: any): void {
eventEmitter.emit(`close:${id}`, payload);
}
public async confirm(payload: IConfirmModalPayload): Promise {
return this.open(MODALS.confirm, payload);
}
}
export default new ComponentsController();
================================================
FILE: packages/components/src/directives/focus.ts
================================================
import {
Directive
} from 'vue';
export default {
mounted(el, binding) {
const {
value,
modifiers
} = binding;
const query = value || 'input, select, textarea';
const element = el.querySelector(query) || el;
if (!element) {
return;
}
element.focus();
if (modifiers.highlight) {
element.setSelectionRange(0, element.value.length);
}
}
} as Directive;
================================================
FILE: packages/components/src/directives/index.ts
================================================
import focus from './focus';
import meta from './meta';
import tooltip from './tooltip';
import visible from './visible';
export default {
focus,
meta,
tooltip,
visible
};
================================================
FILE: packages/components/src/directives/meta.ts
================================================
import {
Directive
} from 'vue';
import {
domSetMeta
} from '@ocula/utilities';
export default {
mounted(element, { arg, value }) {
domSetMeta(arg, value);
},
updated(element, { arg, value }) {
domSetMeta(arg, value);
},
unmounted(element, { arg }) {
domSetMeta(arg);
}
} as Directive;
================================================
FILE: packages/components/src/directives/tooltip.ts
================================================
import {
Directive
} from 'vue';
function upsert(element: HTMLElement, binding) {
const {
value,
arg = 'top'
} = binding;
element.setAttribute('data-tooltip', value);
element.setAttribute('data-tooltip-position', arg);
}
export default {
mounted: upsert,
updated: upsert,
unmounted(element) {
element.removeAttribute('data-tooltip');
element.removeAttribute('data-tooltip-position');
}
} as Directive;
================================================
FILE: packages/components/src/directives/visible.ts
================================================
import type {
Directive,
DirectiveBinding
} from '@vue/runtime-core';
const VISIBILITY_MAP = {
0: 'hidden',
1: 'visible'
};
function updateVisibility(element: HTMLElement, binding: DirectiveBinding) {
element.style.visibility = VISIBILITY_MAP[+!!binding.value];
}
export default {
mounted: updateVisibility,
updated: updateVisibility,
unmounted(element) {
element.style.visibility = null;
}
} as Directive;
================================================
FILE: packages/components/src/event-emitter/index.ts
================================================
import {
EventEmitter
} from '@ocula/event-emitter';
export default new EventEmitter();
================================================
FILE: packages/components/src/helpers/get-listeners.ts
================================================
import {
typeIsFunction
} from '@ocula/utilities';
export default function getListeners(attrs: Record): Record {
const listeners = {};
for (const key in attrs) {
const value = attrs[key];
if (key.startsWith('on') && typeIsFunction(value)) {
listeners[key.replace(/^on/, '').toLowerCase()] = value;
}
}
return listeners;
}
================================================
FILE: packages/components/src/index.ts
================================================
import '@ocula/style/src/index.scss';
import directives from './directives';
import components from './components';
import type {
App
} from 'vue';
type Registrar = 'directive' | 'component';
function register(application: App, registrar: Registrar, dictionary: Record): void {
Object.keys(dictionary).forEach(key => application[registrar].call(application, key, dictionary[key]));
}
// Compositions
export { default as useSubscriber } from './compositions/subscriber';
export { default as useTimer } from './compositions/timer';
export { default as componentsController } from './controllers/components';
// Helpers
export { default as getListeners } from './helpers/get-listeners';
export default {
install(application: App) {
register(application, 'directive', directives);
register(application, 'component', components);
}
};
================================================
FILE: packages/components/src/types/index.ts
================================================
export interface IPromisePayload {
resolve(value?: any): void;
reject(value?: any): void;
};
export interface IConfirmModalPayload {
message: string;
confirmLabel?: string;
cancelLabel?: string;
};
================================================
FILE: packages/event-emitter/package.json
================================================
{
"name": "@ocula/event-emitter",
"version": "1.0.0",
"main": "./src/index.ts"
}
================================================
FILE: packages/event-emitter/src/index.ts
================================================
export interface IListener {
dispose(): void
}
export class EventEmitter {
private listeners: {
[key: string]: Function[]
};
constructor() {
this.listeners = {};
}
on(event: string, handler: Function): IListener {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(handler);
return {
dispose: () => this.off(event, handler)
};
}
off(event: string, handler: Function): void {
const listeners = this.listeners[event];
if (!listeners) {
return;
}
this.listeners[event] = listeners.filter(listener => listener !== handler);
if (this.listeners[event].length === 0) {
delete this.listeners[event];
}
}
once(event: string, handler: Function): IListener {
const callback = (...args) => {
handler(...args);
this.off(event, callback);
};
return this.on(event, callback);
}
emit(event: string, ...args) {
const handlers = this.listeners[event];
if (!handlers) {
return;
}
handlers.forEach(handler => handler(...args));
}
}
export default new EventEmitter();
================================================
FILE: packages/router/package.json
================================================
{
"name": "@ocula/router",
"version": "1.0.0",
"main": "./src/index.ts",
"dependencies": {
"vue-router": "4.0.1"
},
"peerDependencies": {
"vue": "3.0.5"
}
}
================================================
FILE: packages/router/src/index.ts
================================================
import {
createRouter,
createWebHistory
} from 'vue-router';
import type {
App
} from 'vue';
import type {
Router,
RouteRecordRaw
} from 'vue-router';
export type {
RouteRecord,
RouteRecordRaw
} from 'vue-router';
export let router: Router;
export default {
install(app: App, routes: RouteRecordRaw[]) {
router = createRouter({
history: createWebHistory(),
routes
});
app.use(router);
}
};
================================================
FILE: packages/state/package.json
================================================
{
"name": "@ocula/state",
"version": "1.0.0",
"main": "./src/index.ts",
"license": "MIT",
"peerDependencies": {
"vue": "3.0.5"
},
"dependencies": {
"@ocula/utilities": "1.0.0",
"@vue/devtools-api": "^6.0.0-beta.2"
}
}
================================================
FILE: packages/state/src/index.ts
================================================
import {
reactive,
readonly,
computed,
Plugin
} from 'vue';
import {
CustomInspectorNode,
setupDevtoolsPlugin
} from '@vue/devtools-api';
import {
typeIsPlainObject
} from '@ocula/utilities';
import type {
Getter,
Mutation,
IStore
} from './types';
type StoreAccessor = () => object;
const stores = new Map();
let devtoolsEnabled = process.env.NODE_ENV === 'development';
function logMutation(name: string, state: any, isError: boolean = false): void {
if (!devtoolsEnabled) {
return;
}
console[isError ? 'warn' : 'info'](name, 'mutation');
}
function mapStore(state: object): CustomInspectorNode[] {
return Object.keys(state).reduce((output, key) => {
const value = state[key];
if (!typeIsPlainObject(value)) {
return output;
}
return [].concat(output, {
id: key,
label: key,
children: mapStore(value)
});
}, [] as CustomInspectorNode[]);
}
export const plugin = {
install(app) {
setupDevtoolsPlugin({
app,
id: 'state',
label: 'State'
}, api => {
api.addInspector({
id: 'state',
label: 'State'
});
api.on.getInspectorTree((payload, context) => {
if (payload.app !== app || payload.inspectorId !== 'state') {
return;
}
const nodes: CustomInspectorNode[] = [];
stores.forEach((accessor, name) => {
const state = accessor();
nodes.push({
id: name,
label: name,
children: mapStore(state)
});
});
payload.rootNodes = nodes;
});
});
}
} as Plugin;
export function enableDevtools(value: boolean = true): void {
devtoolsEnabled = value;
}
export default function createStore(name: string, data: T): IStore {
const write = reactive(data);
const state = readonly(write);
function getter(getter: Getter) {
return computed(() => getter(state as T));
}
function mutate(name: string, mutation: Mutation): void {
const mutationName = name || mutation.name || 'unknown';
try {
mutation(write as T);
} catch (error) {
logMutation(mutationName, write);
}
logMutation(mutationName, write);
}
function destroy() {
stores.delete(name);
}
stores.set(name, () => state);
return {
state: state as T,
getter,
mutate,
destroy
};
}
================================================
FILE: packages/state/src/types/index.ts
================================================
import type {
ComputedRef
} from '@vue/reactivity';
export type Getter = (state: T) => U;
export type Mutation = (state: T) => void;
export interface IStore {
state: T;
getter(getter: Getter): ComputedRef;
mutate(name:string, mutation: Mutation): void;
destroy(): void;
};
================================================
FILE: packages/state/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"DOM",
"ESNext"
]
}
}
================================================
FILE: packages/style/package.json
================================================
{
"name": "@ocula/style",
"version": "1.0.0",
"main": "./src/index.scss",
"license": "MIT",
"dependencies": {
"flex-layout-attribute": "^1.0.3"
}
}
================================================
FILE: packages/style/src/_base.scss
================================================
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: auto;
}
html {
font-size: 16px;
box-sizing: border-box;
-moz-box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
font-family: var(--font__family);
font-size: var(--font__size);
font-weight: var(--font__weight);
line-height: 1.5;
color: var(--font__colour);
background-color: var(--background__colour);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
}
@media (hover: hover) {
::-webkit-scrollbar {
width: 16px;
height: 16px;
}
::-webkit-scrollbar-track {
background-color: var(--background__colour);
}
::-webkit-scrollbar-thumb {
width: 8px;
height: 8px;
min-height: 32px;
background-color: rgba(#CCCCCC, 0.75);
background-clip: padding-box;
border: 4px solid transparent;
border-radius: 8px;
&:hover {
background-color: #CCCCCC;
}
}
}
================================================
FILE: packages/style/src/_buttons.scss
================================================
button,
.button {
display: inline-block;
padding: var(--spacing__x-small) var(--spacing__medium);
font: inherit;
font-weight: var(--font__weight--heavy);
border: none;
border-radius: var(--border__radius);
background-color: #EEEEEE;
cursor: pointer;
transition: background var(--transition__timing) var(--transition__easing--default);
&:hover,
&:focus {
background-color: darken(#EEEEEE, 20%);
}
}
.button--primary {
color: var(--font__colour--compliment);
background-color: var(--colour__primary);
&:hover,
&:focus {
background-color: var(--colour__primary--dark);
}
}
.button--ghost {
font-weight: var(--font__weight);
background: none;
&:hover,
&:focus {
background: none;
}
&:focus {
outline: none;
}
}
================================================
FILE: packages/style/src/_dots.scss
================================================
.dot {
display: inline-block;
width: 0.5em;
height: 0.5em;
border-radius: 50%;
vertical-align: middle;
background-color: var(--border__colour);
}
.dot--large {
width: 1em;
height: 1em;
}
================================================
FILE: packages/style/src/_grid.scss
================================================
$min-size: 1;
$max-size: 12;
$alignments: (
auto,
start,
end,
center,
stretch
);
[grid] {
display: grid;
grid-gap: var(--spacing__small);
}
@for $size from $min-size to $max-size {
[grid^="#{$size}"] {
grid-template-columns: repeat($size, 1fr);
}
}
@each $alignment in $alignments {
[grid*="#{$alignment}-"] {
justify-items: $alignment;
}
[grid*="-#{$alignment}"] {
align-items: $alignment;
}
[grid-self^="#{$alignment}"] {
justify-self: $alignment;
}
[grid-self*="-#{$alignment}"] {
align-self: $alignment;
}
}
[grid-gap="none"] {
grid-gap: 0;
}
@each $spacing-name, $spacing-value in $spacings {
[grid-gap="#{$spacing-name}"] {
grid-gap: var(--spacing__#{ $spacing-name });
}
[grid-gap|="#{$spacing-name}"] {
grid-column-gap: var(--spacing__#{ $spacing-name });
}
[grid-gap$="-#{$spacing-name}"] {
grid-row-gap: var(--spacing__#{ $spacing-name });
}
}
@each $breakpoint-name, $breakpoint-value in $breakpoints {
@include breakpoint($breakpoint-name) {
@for $size from $min-size to $max-size {
[grid*="#{$breakpoint-name}-#{$size}"] {
grid-template-columns: repeat($size, 1fr);
}
}
}
}
================================================
FILE: packages/style/src/_inputs.scss
================================================
input[type="text"],
input[type="date"],
input[type="time"],
input[type="datetime"],
input[type="number"],
input[type="password"],
input[type="email"],
input[type="search"],
textarea,
select {
display: block;
width: 100%;
padding: var(--spacing__x-small) var(--spacing__small);
color: inherit;
font-size: var(--font__size);
background-color: var(--background__colour);
border: 1px solid var(--border__colour);
border-radius: var(--border__radius);
outline: none;
}
input[type="range"] {
margin: 0;
background-color: transparent;
-webkit-appearance: none;
@mixin track {
box-sizing: border-box;
width: 100%;
height: 0.5rem;
background: var(--background__colour--hover);
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
@mixin thumb {
box-sizing: border-box;
width: 1rem;
height: 1rem;
margin-top: -0.25rem;
background: var(--colour__primary);
border: none;
border-radius: 50%;
transition: background var(--transition__timing) var(--transition__easing--default);
cursor: pointer;
-webkit-appearance: none;
}
&:focus,
&:active {
outline: none;
// &::-webkit-slider-runnable-track {
// background: var(--colour__primary);
// }
// &::-moz-range-track {
// background: var(--colour__primary);
// }
// &::-ms-track {
// background: var(--colour__primary);
// }
}
/* Track */
&::-webkit-slider-runnable-track {
@include track;
}
&::-moz-range-track {
@include track;
}
&::-ms-track {
@include track;
}
/* Thumb */
&::-webkit-slider-thumb {
@include thumb;
&:hover {
background: var(--colour__primary--dark);
}
}
&::-moz-range-thumb {
@include thumb;
&:hover {
background: var(--colour__primary--dark);
}
}
&::-ms-thumb {
@include thumb;
margin-top: 0;
&:hover {
background: var(--colour__primary--dark);
}
}
}
================================================
FILE: packages/style/src/_menus.scss
================================================
.menu {
display: block;
}
.menu-item {
padding: var(--spacing__small);
border-radius: var(--border__radius);
cursor: pointer;
}
.menu-item--active {
color: var(--colour__primary);
}
@media (hover: hover) {
.menu-item {
&:hover {
background-color: var(--background__colour--hover);
}
}
}
================================================
FILE: packages/style/src/_mixins.scss
================================================
@import "./_variables.scss";
@mixin text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin breakpoint($breakpoint) {
$size: map-get($breakpoints, $breakpoint);
@media screen and (min-width: #{ $size }) {
@content;
}
}
================================================
FILE: packages/style/src/_spacing.scss
================================================
$directions: (
top: (top),
right: (right),
bottom: (bottom),
left: (left),
vertical: (top, bottom),
horizontal: (left, right),
all: (top, right, bottom, left)
);
$spacing-properties: (
margin,
padding
);
@each $property-name in $spacing-properties {
.#{ $property-name }__all--none {
#{ $property-name }-top: 0;
#{ $property-name }-right: 0;
#{ $property-name }-bottom: 0;
#{ $property-name }-left: 0;
}
@each $direction-name, $direction-value in $directions {
.#{ $property-name }__#{ $direction-name }--none {
@each $direction-property in $direction-value {
#{ $property-name }-#{ $direction-property }: 0;
}
}
@each $spacing-name, $spacing-value in $spacings {
.#{ $property-name }__#{ $direction-name }--#{ $spacing-name } {
@each $direction-property in $direction-value {
#{ $property-name }-#{ $direction-property }: var(--spacing__#{ $spacing-name });
}
}
}
}
}
================================================
FILE: packages/style/src/_tables.scss
================================================
table {
border-collapse: collapse;
}
th,
td {
padding: var(--spacing__x-small);
}
.table--fixed {
width: 100%;
max-width: 100%;
table-layout: fixed;
}
================================================
FILE: packages/style/src/_tooltips.scss
================================================
@media (hover: hover) {
[data-tooltip] {
position: relative;
&::after {
display: block;
position: absolute;
content: attr(data-tooltip);
width: auto;
padding: var(--spacing__xx-small) var(--spacing__x-small);
font-size: var(--font__size--small);
color: var(--tooltip__font-colour);
white-space: nowrap;
background: var(--tooltip__background);
border-radius: 3px;
overflow: hidden;
opacity: 0;
transition: opacity var(--transition__timing--long) var(--transition__easing--default) 500ms;
}
&:hover {
&::after {
opacity: 1;
}
}
}
[data-tooltip-position="top"],
[data-tooltip-position="bottom"] {
&::after {
left: 50%;
}
}
[data-tooltip-position="left"],
[data-tooltip-position="right"] {
&::after {
top: 50%;
}
}
[data-tooltip-position="top"] {
&::after {
bottom: 100%;
transform: translate(-50%, -0.5rem);
}
}
[data-tooltip-position="bottom"] {
&::after {
top: 100%;
transform: translate(-50%, 0.5rem);
}
}
[data-tooltip-position="left"] {
&::after {
right: 100%;
transform: translate(-0.5rem, -50%);
}
}
[data-tooltip-position="right"] {
&::after {
left: 100%;
transform: translate(0.5rem, -50%);
}
}
}
@keyframes tooltip {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
================================================
FILE: packages/style/src/_typography.scss
================================================
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: var(--font__weight--heavy);
}
a,
.link {
text-decoration: none;
}
a {
color: var(--colour__primary);
&:hover {
color: var(--colour__primary--dark);
}
}
.link {
color: inherit;
}
.link--inherit {
color: inherit;
&:hover {
color: inherit;
}
}
.text--meta {
color: var(--font__colour--meta);
}
.text--medium {
font-weight: var(--font__weight--medium);
}
strong,
.text--heavy {
font-weight: var(--font__weight--heavy);
}
small,
.text--small {
font-size: var(--font__size--small);
}
.text--x-small {
font-size: var(--font__size--x-small);
}
.text--left {
text-align: left;
}
.text--centre {
text-align: center;
}
.text--right {
text-align: right;
}
.text--tight {
line-height: 1;
}
.text--no-wrap {
overflow: hidden;
white-space: nowrap;
}
.text--truncate {
@include text-truncate;
}
@media (hover: hover) {
a {
&:hover {
color: var(--colour__primary--dark);
}
}
}
================================================
FILE: packages/style/src/_variables.scss
================================================
$spacings: (
xx-small: 0.25rem,
x-small: 0.5rem,
small: 1rem,
medium: 1.5rem,
large: 2rem,
x-large: 3rem,
xx-large: 4rem
);
$breakpoints: (
lg: 64em,
md: 52em,
sm: 40em
);
$colour__primary: #5D9BE5;
$colour__primary--light: lighten(#5D9BE5, 20%);
$colour__primary--dark: darken(#5D9BE5, 20%);
:root {
--font__size: 14px;
--font__size--small: 0.875em;
--font__size--x-small: 0.75em;
--font__size--large: 1.25em;
--font__size--x-large: 1.5em;
--font__weight: 400;
--font__weight--medium: 500;
--font__weight--heavy: 700;
--font__family: 'DM Sans', sans-serif;
--font__colour: #353539;
--font__colour--compliment: #F7F7F7;
--font__colour--meta: #9999AA;
--transition__timing: 250ms;
--transition__timing--long: 400ms;
--transition__timing--fade: 1s;
--transition__easing--default: cubic-bezier(0.165, 0.84, 0.44, 1); // quartic-out
--colour__primary: #5D9BE5;
--colour__primary--light: #{ lighten(#5D9BE5, 20%) };
--colour__primary--dark: #{ darken(#5D9BE5, 20%) };
--background__colour: #FFFFFF;
--background__colour--hover: #EEEEEE;
--border__colour: #EDEDED;
--border__radius: 5px;
--border__radius--large: 1rem;
--tooltip__background: #333333;
--tooltip__font-colour: #FFFFFF;
@each $name, $value in $spacings {
--spacing__#{ $name }: #{ $value };
}
}
================================================
FILE: packages/style/src/index.scss
================================================
@import "~flex-layout-attribute/sass/flex-layout-attribute.scss";
@import "./_variables.scss";
@import "./_mixins.scss";
@import "./_base.scss";
@import "./_spacing.scss";
@import "./_grid.scss";
@import "./_typography.scss";
@import "./_buttons.scss";
@import "./_inputs.scss";
@import "./_tables.scss";
@import "./_menus.scss";
@import "./_dots.scss";
@import "./_tooltips.scss";
================================================
FILE: packages/task-queue/package.json
================================================
{
"name": "@ocula/task-queue",
"version": "1.0.0",
"main": "./src/index.ts"
}
================================================
FILE: packages/task-queue/src/index.ts
================================================
export class TaskQueue {
private _tasks: Set;
private _suppressErrors: boolean;
constructor(suppressErrors: boolean = false) {
this._tasks = new Set();
this._suppressErrors = suppressErrors;
}
get suppressErrors() {
return this._suppressErrors;
}
set suppressErrors(value) {
this._suppressErrors = !!value;
}
add(task: Function): void {
this._tasks.add(task);
}
remove(task: Function): void {
this._tasks.delete(task);
}
clear(): void {
this._tasks.clear();
}
run(...args: any[]): void {
this._tasks.forEach(task => {
try {
task(...args);
} catch (error) {
if (!this._suppressErrors) {
throw error;
}
} finally {
this.remove(task);
}
});
}
}
export default new TaskQueue();
================================================
FILE: packages/utilities/package.json
================================================
{
"name": "@ocula/utilities",
"version": "1.0.0",
"main": "./src/index.ts",
"license": "MIT",
"dependencies": {
"date-fns": "^2.16.1",
"date-fns-tz": "^1.0.12",
"lodash": "^4.17.20",
"nanoid": "^3.1.20"
},
"devDependencies": {
"@types/lodash": "^4.14.165"
}
}
================================================
FILE: packages/utilities/src/array/join-by.ts
================================================
import getAccessor from '../value/get-accessor';
type Iteratee = (value: T) => string;
export default function joinBy(array: T[], iteratee: Iteratee = value => String(value), separator: string = ','): string {
const accessor = getAccessor(iteratee);
const trimmer = new RegExp(`${separator}$`);
return array.reduce((output, value) => output + accessor(value) + separator, '')
.replace(trimmer, '');
}
================================================
FILE: packages/utilities/src/array/order-by.ts
================================================
export { default } from 'lodash/orderBy';
================================================
FILE: packages/utilities/src/array/swap-by.ts
================================================
import isNumber from '../type/is-number';
import clamp from '../number/clamp';
type Predicate = (value: T) => boolean;
function getIndex(array: T[], predicate: number | Predicate): number {
const index = isNumber(predicate) ? predicate : array.findIndex(predicate);
return clamp(index, 0, array.length - 1);
}
export default function swapBy(array: T[], predicateA: number | Predicate, predicateB: number | Predicate): T[] {
const clone = array.slice();
const indexA = getIndex(array, predicateA);
const indexB = getIndex(array, predicateB);
clone[indexA] = array[indexB];
clone[indexB] = array[indexA];
return clone;
}
================================================
FILE: packages/utilities/src/array/union-with.ts
================================================
export { default } from 'lodash/unionWith';
================================================
FILE: packages/utilities/src/array/unique-by.ts
================================================
export { default } from 'lodash/uniqBy';
================================================
FILE: packages/utilities/src/date/format-distance-to-now.ts
================================================
export { default } from 'date-fns/formatDistanceToNow';
================================================
FILE: packages/utilities/src/date/format-distance.ts
================================================
export { default } from 'date-fns/formatDistance';
================================================
FILE: packages/utilities/src/date/format.ts
================================================
export { default } from 'date-fns-tz/format';
================================================
FILE: packages/utilities/src/date/from-unix.ts
================================================
export { default } from 'date-fns/fromUnixTime';
================================================
FILE: packages/utilities/src/date/is-today.ts
================================================
export { default } from 'date-fns/isToday';
================================================
FILE: packages/utilities/src/date/to-unix.ts
================================================
export { default } from 'date-fns/getUnixTime';
================================================
FILE: packages/utilities/src/date/utc-to-zoned.ts
================================================
export { default } from 'date-fns-tz/utcToZonedTime';
================================================
FILE: packages/utilities/src/dom/set-meta.ts
================================================
export default function setMeta(key: string, value: string = ''): void {
let meta = document.querySelector(`meta[name=${key}]`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', key);
document.head.appendChild(meta);
}
meta.setAttribute('content', value);
}
================================================
FILE: packages/utilities/src/env/_base/is-env.ts
================================================
type Environment = 'development' | 'production';
export default function isEnv(environment: Environment): boolean {
return process.env.NODE_ENV === environment;
}
================================================
FILE: packages/utilities/src/env/is-development.ts
================================================
import isEnv from './_base/is-env';
export default isEnv('development');
================================================
FILE: packages/utilities/src/env/is-production.ts
================================================
import isEnv from './_base/is-env';
export default isEnv('production');
================================================
FILE: packages/utilities/src/function/debounce.ts
================================================
export { default } from 'lodash/debounce';
================================================
FILE: packages/utilities/src/function/identity.ts
================================================
export default function identity(value) {
return value;
}
================================================
FILE: packages/utilities/src/function/noop.ts
================================================
export { default } from 'lodash/noop';
================================================
FILE: packages/utilities/src/index.ts
================================================
export { default as arrayJoinBy } from './array/join-by';
export { default as arrayOrderBy } from './array/order-by';
export { default as arraySwapBy } from './array/swap-by';
export { default as arrayUnionWith } from './array/union-with';
export { default as arrayUniqueBy } from './array/unique-by';
export { default as dateFormat } from './date/format';
export { default as dateFormatDistance } from './date/format-distance';
export { default as dateFormatDistanceToNow } from './date/format-distance-to-now';
export { default as dateFromUnix } from './date/from-unix';
export { default as dateIsToday } from './date/is-today';
export { default as dateToUnix } from './date/to-unix';
export { default as dateUtcToZoned } from './date/utc-to-zoned';
export { default as domSetMeta } from './dom/set-meta';
export { default as envIsDevelopment } from './env/is-development';
export { default as envIsProduction } from './env/is-production';
export { default as functionDebounce } from './function/debounce';
export { default as functionIdentity } from './function/identity';
export { default as numberClamp } from './number/clamp';
export { default as numberMinBy } from './number/min-by';
export { default as numberMaxBy } from './number/max-by';
export { default as numberPercentage } from './number/percentage';
export { default as numberRound } from './number/round';
export { default as objectCloneLazy } from './object/clone-lazy';
export { default as objectMerge } from './object/merge';
export { default as objectMergeWith } from './object/merge-with';
export { default as objectTransform } from './object/transform';
export { default as scaleContinuous } from './scale/continuous';
export { default as scaleDiscrete } from './scale/discrete';
export { default as stringCapitalize } from './string/capitalize';
export { default as stringUniqueId } from './string/unique-id';
export { default as typeIsArray } from './type/is-array';
export { default as typeIsDate } from './type/is-date';
export { default as typeIsFunction } from './type/is-function';
export { default as typeIsNil } from './type/is-nil';
export { default as typeIsNumber } from './type/is-number';
export { default as typeIsPlainObject } from './type/is-plain-object';
export { default as typeIsString } from './type/is-string';
export { default as valueGetAccessor } from './value/get-accessor';
================================================
FILE: packages/utilities/src/number/clamp.ts
================================================
export { default } from 'lodash/clamp';
================================================
FILE: packages/utilities/src/number/max-by.ts
================================================
export { default } from 'lodash/maxBy';
================================================
FILE: packages/utilities/src/number/min-by.ts
================================================
export { default } from 'lodash/minBy';
================================================
FILE: packages/utilities/src/number/percentage.ts
================================================
import round from './round';
export default function(value: number, precision: number = 0): string {
return `${round(value * 100, precision)}%`;
}
================================================
FILE: packages/utilities/src/number/round.ts
================================================
export { default } from 'lodash/round';
================================================
FILE: packages/utilities/src/object/clone-lazy.ts
================================================
export default function cloneLazy(value: T): T {
return JSON.parse(JSON.stringify(value));
}
================================================
FILE: packages/utilities/src/object/merge-with.ts
================================================
import mergeWith from 'lodash/mergeWith';
export default function(...args) {
return mergeWith({}, ...args);
}
================================================
FILE: packages/utilities/src/object/merge.ts
================================================
import merge from 'lodash/merge';
// Convert merge to be immutable
export default function(...sources) {
return merge({}, ...sources);
};
================================================
FILE: packages/utilities/src/object/transform.ts
================================================
import functionIdentity from '../function/identity';
import typeIsArray from '../type/is-array';
import typeIsFunction from '../type/is-function';
import typeIsNil from '../type/is-nil';
import typeIsPlainObject from '../type/is-plain-object';
type Transformer = (value: any, key?: PropertyKey, input?: T) => any;
type SchemaValue = any | any[] | Transformer | Object;
interface Schema {
[key: string]: Transformer | Schema | Schema[]
}
function getTransformer(schemaValue: SchemaValue, baseTransformer: Transformer): Transformer {
switch (true) {
case typeIsFunction(schemaValue):
return schemaValue;
case typeIsArray(schemaValue):
return value => transformArray(value, schemaValue, baseTransformer);
case typeIsPlainObject(schemaValue):
return value => transformObject(value, schemaValue, baseTransformer);
default:
return baseTransformer;
}
}
function transformArray(input: any[], schemaValue: any[], baseTransformer: Transformer): any[] {
const transformer = getTransformer(schemaValue[0], baseTransformer);
return input.map(transformer);
}
function transformObject(input: T, schema: Schema, baseTransformer: Transformer): U {
const output: U = {};
for (const key in input) {
const schemaValue = schema[key];
const transformer = getTransformer(schemaValue, baseTransformer);
const value = transformer(input[key], key, input);
if (!typeIsNil(value)) {
output[key] = value;
}
}
return output;
}
export default function transform(input: T, schema: Schema, baseTransformer: Transformer = functionIdentity): U {
return transformObject(input, schema, baseTransformer);
}
================================================
FILE: packages/utilities/src/scale/_base/scale.ts
================================================
import numberClamp from '../../number/clamp';
type Calculation = (value: T) => number;
export interface IScale {
(value: T, clamp?: boolean): number,
domain: T[],
range: number[],
}
export default function scale(domain: T[], range: number[], calculation: Calculation): IScale {
const [
min,
max
] = range;
const output: IScale = (value: T, clamp: boolean) => {
let result = calculation(value);
if (clamp) {
result = numberClamp(result, min, max);
}
return result;
};
output.domain = domain;
output.range = range;
return output;
}
================================================
FILE: packages/utilities/src/scale/continuous.ts
================================================
import scale from './_base/scale';
import type {
IScale
} from './_base/scale';
export default function continuous(
domain: number[],
range: number[],
): IScale {
const [
domainMin,
domainMax
] = domain;
const [
rangeMin,
rangeMax
] = range;
const domainLength = domainMax - domainMin;
const rangeLength = rangeMax - rangeMin;
return scale(domain, range, value => {
return (value - domainMin) * rangeLength / domainLength + rangeMin;
});
};
================================================
FILE: packages/utilities/src/scale/discrete.ts
================================================
import scale from './_base/scale';
import type {
IScale
} from './_base/scale';
export default function discrete(
domain: T[],
range: number[],
): IScale {
const [
rangeMin,
rangeMax
] = range;
const rangeLength = rangeMax - rangeMin;
const domainLength = domain.length;
const step = rangeLength / domainLength;
return scale(domain, range, value => {
return rangeMin + (domain.indexOf(value) * step);
});
};
================================================
FILE: packages/utilities/src/string/capitalize.ts
================================================
export { default } from 'lodash/capitalize';
================================================
FILE: packages/utilities/src/string/unique-id.ts
================================================
import { nanoid } from 'nanoid';
export default function uniqueId(length: number = 6): string {
return nanoid(length);
}
================================================
FILE: packages/utilities/src/type/is-array.ts
================================================
export { default } from 'lodash/isArray';
================================================
FILE: packages/utilities/src/type/is-date.ts
================================================
export { default } from 'lodash/isDate';
================================================
FILE: packages/utilities/src/type/is-function.ts
================================================
export { default } from 'lodash/isFunction';
================================================
FILE: packages/utilities/src/type/is-nil.ts
================================================
export { default } from 'lodash/isNil';
================================================
FILE: packages/utilities/src/type/is-number.ts
================================================
export { default } from 'lodash/isNumber';
================================================
FILE: packages/utilities/src/type/is-plain-object.ts
================================================
export { default } from 'lodash/isPlainObject';
================================================
FILE: packages/utilities/src/type/is-string.ts
================================================
export { default } from 'lodash/isString';
================================================
FILE: packages/utilities/src/value/get-accessor.ts
================================================
import isNil from '../type/is-nil';
import isFunction from '../type/is-function';
import noop from '../function/noop';
type Product = (...args: any[]) => T;
export default function getAccessor(identity: T | Product): Product {
if (isNil(identity)) {
return noop as any;
}
return isFunction(identity) ? identity : () => identity;
}
================================================
FILE: packages/utilities/tsconfig.json
================================================
{
"extends": "../../tsconfig.json"
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"allowSyntheticDefaultImports": true,
"noImplicitAny": false,
"resolveJsonModule": true
},
"exclude": [
"public",
"dist",
"node_modules"
]
}
================================================
FILE: vercel.json
================================================
{
"version": 2,
"env": {
"OWM_API_KEY": "@owm-api-key",
"WORLDTIDES_API_KEY": "@worldtides-api-key",
"MAPBOX_API_KEY": "@mapbox-api-key",
"GA_TRACKING_ID": "@ga-tracking-id",
"SENTRY_DSN": "@sentry-dsn"
},
"build": {
"env": {
"OWM_API_KEY": "@owm-api-key",
"WORLDTIDES_API_KEY": "@worldtides-api-key",
"MAPBOX_API_KEY": "@mapbox-api-key",
"GA_TRACKING_ID": "@ga-tracking-id",
"SENTRY_DSN": "@sentry-dsn"
}
},
"routes": [
{
"handle": "filesystem"
},
{
"src": "/.*",
"dest": "/index.html"
}
]
}