Full Code of andrewcourtice/ocula for AI

master f49a46a1b83b cached
290 files
247.0 KB
67.0k tokens
206 symbols
1 requests
Download .txt
Showing preview only (312K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<p align="center">
    <a href="https://app.ocula.io">
        <img src="https://github.com/andrewcourtice/ocula/raw/master/client/src/assets/images/logo/logo-192.svg" alt="Ocula"/>
    </a>
</p>

# Ocula
The free and open-source progressive weather app

<!-- TOC depthfrom:2 -->

- [About](#about)
- [Features](#features)
- [Philosophy](#philosophy)
- [Donating](#donating)
- [Credits](#credits)

<!-- /TOC -->

## 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.

<p align="center">
    <img src="https://user-images.githubusercontent.com/11718453/93705532-95b09a80-fb61-11ea-89d9-e72e6146aea2.png" width="192" />
    <img src="https://user-images.githubusercontent.com/11718453/93705531-93e6d700-fb61-11ea-8201-80efecfc95d3.png" width="192" />
    <img src="https://user-images.githubusercontent.com/11718453/93705526-8e898c80-fb61-11ea-82aa-cf381b5e13a3.png" width="192" />
</p>
<p align="center">
    <img src="https://user-images.githubusercontent.com/11718453/94127849-c57edb80-fe9c-11ea-9590-34e43c0b2ae0.png" width="192" />
    <img src="https://user-images.githubusercontent.com/11718453/94127875-cb74bc80-fe9c-11ea-8f7c-47550a6a3607.png" width="192" />
    <img src="https://user-images.githubusercontent.com/11718453/93705522-87fb1500-fb61-11ea-8b2d-cefa59c9c712.png" width="192" />
</p>

## 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<string, any>): Record<string, any> {
    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
================================================
<template>
    <layout class="app transition-theme-change" :class="appClass" footer>
        <router-view />
        <template #footer>
            <nav class="app__nav">
                <container class="app__nav-container" layout="row center-stretch" :grid="routes.length">
                    <router-link class="app__route" v-for="route in routes" :key="route.label" :to="route.route">
                        <icon-button class="app__route-button" layout="vertical" :icon="route.icon">
                            <small>{{ route.label }}</small>
                        </icon-button>
                    </router-link>
                </container>
            </nav>
        </template>
        <location-modal />
        <core-components />
    </layout>
</template>

<script lang="ts">
import ROUTES from './constants/core/routes';

import LocationModal from './components/modals/location.vue';

import setThemeMeta from './helpers/set-theme-meta';

import {
    defineComponent,
    watch,
    computed
} from 'vue';

import {
    phase,
    theme
} from './store';
import PHASE from './enums/forecast/phase';

const routes = [
    {
        label: 'Forecast',
        icon: 'sun-line',
        route: {
            name: ROUTES.forecast.index
        }
    },
    {
        label: 'Maps',
        icon: 'road-map-line',
        route: {
            name: ROUTES.maps.index
        }
    },
    {
        label: 'Settings',
        icon: 'equalizer-line',
        route: {
            name: ROUTES.settings.index
        }
    }
];

const PHASE_CLASS = {
    [PHASE.day]: 'phase--day',
    [PHASE.night]: 'phase--night'
};

export default defineComponent({

    components: {
        LocationModal
    },

    setup() {
        watch(() => theme.value.core, ({ colour }) => setThemeMeta(colour));

        const appClass = computed(() => [
            PHASE_CLASS[phase.value],
            theme.value.core.class
        ]);
        
        return {
            appClass,
            routes
        };
    }

});
</script>

<style lang="scss">

    .app {
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        user-select: none;
        color: var(--font__colour);
        background-color: var(--background__colour);
    }

    .app__nav {
        border-top: 1px solid var(--border__colour);
    }

    .app__nav-container {
        padding: var(--spacing__x-small) var(--spacing__medium);
    }

    .app__route {
        display: block;
        color: inherit;

        &:hover {
            color: inherit;
        }

        &.router-link-active {
            color: var(--colour__primary);
        }
    }

    .app__route-button {
        display: flex;
    }

    .route {
        width: 100%;
        height: 100%;
        overflow: hidden;
        overflow-y: auto;
    }

    .transition-theme-change {
        transition: color var(--transition__timing--fade) var(--transition__easing--default),
                    background var(--transition__timing--fade) var(--transition__easing--default);
    }

</style>

================================================
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<Element>(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
================================================
<template>
    <div class="line-chart" ref="element"></div>
</template>

<script lang="ts">
import {
    LineChart
} from '@ocula/charts';

import chart from './_base/chart';

export default chart(LineChart);
</script>

<style lang="scss">

    .line-chart {
        width: 100%;
        height: 100%;
    }

</style>

================================================
FILE: client/src/components/charts/trends.vue
================================================
<template>
    <div class="trends-chart">
        <div class="trends-chart__body" :style="bodyStyle">
            <line-chart class="trends-chart__chart" :data="data" :options="options" />
            <div class="trends-chart__now" layout="column center-left">
                <span class="trends-chart__now-label">
                    <slot name="start-label" :value="data[0]">Now</slot>
                </span>
            </div>
            <template v-for="value in data.slice(1, -1)" :key="keyBy(value)">
                <div class="trends-chart__column">
                    <slot name="primary-column" :value="value"></slot>
                </div>
                <div class="trends-chart__column" v-for="row in secondaryRows" :key="row">
                    <slot name="secondary-column" :value="value" :row="row"></slot>
                </div>
            </template>
            <div class="trends-chart__later" layout="column center-right">
                <span class="trends-chart__later-label">
                    <slot name="end-label" :value="data[data.length - 1]">Later</slot>
                </span>
            </div>
        </div>
    </div>
</template>

<script lang="ts">
import LineChart from './line.vue';

import {
    defineComponent,
    computed,
    PropType
} from 'vue';

import type {
    ILineOptions
} from '@ocula/charts';

export default defineComponent({

    components: {
        LineChart
    },
    
    props: {

        data: {
            type: Array,
            default: () => []
        },

        options: {
            type: Object as PropType<ILineOptions>
        },

        keyBy: {
            type: Function
        },

        secondaryRows: {
            type: Number,
            default: 0
        }

    },

    setup(props) {

        const bodyStyle = computed(() => ({
            gridTemplateRows: `repeat(${props.secondaryRows + 2}, auto)`,
            gridTemplateColumns: `2rem repeat(${props.data.length - 2}, 4rem) 2rem`
        }));

        return {
            bodyStyle
        };
    }

});
</script>

<style lang="scss">
    @import "~@ocula/style/src/_mixins.scss";

    .trends-chart {
        overflow: hidden;
        overflow-x: auto;
    }

    .trends-chart__body {
        display: inline-grid;
        padding-bottom: var(--spacing__small);
        grid-auto-flow: column;
        row-gap: var(--spacing__x-small);
        width: auto;
    }

    .trends-chart__chart {
        grid-column: 1 / -1;
        height: 196px;
    }

    .trends-chart__column {
        @include text-truncate;
        padding: 0 var(--spacing__xx-small);
        text-align: center;
    }

    .trends-chart__now,
    .trends-chart__later {
        grid-row: 2 / -1;      
    }

    .trends-chart__now-label,
    .trends-chart__later-label {
        color: var(--font__colour--meta);
        font-size: var(--font__size--x-small);
        text-transform: uppercase;
        transform-origin: center;
    }

    .trends-chart__now-label {
        transform: rotate(90deg);
    }
    
    .trends-chart__later-label {
        transform: rotate(-90deg);
    }

</style>

================================================
FILE: client/src/components/drawers/maps.vue
================================================
<template>
    <drawer :id="id" class="maps-drawer" position="top" ref="drawer">
        <template #default="{ close }">
            <container class="maps-drawer__container" grid="3">
                <router-link class="link--inherit" :to="getRoute(key)" v-for="(value, key) in maps" :key="key">
                    <icon-button class="maps-drawer__option menu-item" layout="vertical" :icon="value.icon" @click.native="close">
                        <small>{{ value.label }}</small>
                    </icon-button>
                </router-link>
            </container>
        </template>
    </drawer>
</template>

<script lang="ts">
import MAP from '../../enums/maps/map';

import DRAWERS from '../../constants/core/drawers';
import MAPS from '../../constants/maps/maps';
import ROUTES from '../../constants/core/routes';

import {
    defineComponent,
    ref
} from 'vue';

export default defineComponent({
    
    setup() {
        const id = DRAWERS.maps;
        const drawer = ref(null);

        function getRoute(type: MAP) {
            return {
                name: ROUTES.maps.index,
                params: {
                    type
                }
            };
        }

        return {
            id,
            drawer,
            maps: MAPS,
            getRoute
        };
    }

})
</script>

<style lang="scss">

    .maps-drawer__container {
        padding: var(--spacing__small);
    }

    .maps-drawer__option {
        display: flex;
    }

</style>


================================================
FILE: client/src/components/forecast/daily-forecast.vue
================================================
<template>
    <accordion class="forecast-daily">
        <template #default="accordion">
            <table class="forecast-daily__days">
                <tbody class="forecast-daily__day" v-for="day in days" :key="day.dt.raw">
                    <tr class="forecast-daily__day-header menu-item" @click="accordion.toggle(day.dt.raw)">
                        <td class="forecast-daily__day-column forecast-daily__day-column--icon">
                            <icon :name="getIcon(day.weather.id.raw)"/>
                        </td>
                        <td class="forecast-daily__day-column forecast-daily__day-column--label">
                            <div>{{ getDate(day) }}</div>
                            <div class="text--meta text--tight">
                                <small>{{ day.weather.description.formatted }}</small>
                            </div>
                        </td>
                        <td class="forecast-daily__day-column forecast-daily__day-column--precip">
                            <div layout="row center-right" v-if="day.pop.raw > 0">
                                <div class="text--meta">{{ day.pop.formatted }}</div>
                                <icon name="drop-fill" class="forecast-daily__precip-icon" :style="getPrecipIconStyle(day)"/>
                            </div>
                        </td>
                        <td class="forecast-daily__day-column forecast-daily__day-column--min">{{ getMinMax(day.temp.min) }}</td>
                        <td class="forecast-daily__day-column forecast-daily__day-column--max">{{ getMinMax(day.temp.max) }}</td>
                    </tr>
                    <tr class="forecast-daily__day-body">
                        <td colspan="5">
                            <accordion-pane :id="day.dt.raw">
                                <div class="forecast-daily__day-details" grid="2 md-3">
                                    <observation class="forecast-daily__day-observation" label="Temp Min" icon="temp-cold-line">{{ day.temp.min.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Temp Max" icon="temp-hot-line">{{ day.temp.max.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Wind Speed" icon="windy-line">{{ day.windSpeed.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Wind Direction" icon="compass-3-line">{{ day.windDeg.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Humidity" icon="contrast-drop-2-line">{{ day.humidity.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Pressure" icon="swap-line">{{ day.pressure.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="Cloud Coverage" icon="cloudy-line">{{ day.clouds.formatted }}</observation>
                                    <observation class="forecast-daily__day-observation" label="UV Index" icon="sun-line">
                                        <div layout="row center-left">
                                            <div class="margin__right--x-small">{{ day.uvi.formatted }}</div>
                                            <div class="dot" :style="getUvIndexDotStyle(day.uvi.raw)"></div>
                                        </div>
                                    </observation>
                                </div>
                            </accordion-pane>
                        </td>
                    </tr>
                </tbody>
            </table>
        </template>
    </accordion>
</template>

<script lang="ts">
import UV_INDEX from '../../constants/forecast/uv-index';

import Observation from '../weather/observation.vue';

import getIcon from '../../helpers/get-icon';

import {
    defineComponent,
    computed
} from 'vue';

import {
    forecast,
    format
} from '../../store';

import {
    scaleContinuous
} from '@ocula/utilities';

import type {
    Formatted,
    IMappedForecastDay
} from '../../types/state';

export default defineComponent({

    components: {
        Observation
    },
    
    setup() {
        const precipScale = scaleContinuous([0, 1], [0.5, 1]);
        const days = computed(() => forecast.value.daily);

        function getDate(day: Formatted<IMappedForecastDay>) {
            return format.value.date(day.dt.formatted as any);
        }

        function getPrecipIconStyle(day: Formatted<IMappedForecastDay>) {
            return {
                opacity: precipScale(day.pop.raw, true)
            };
        }

        function getMinMax(value) {
            return Math.round(value.raw);
        }

        function getUvIndexDotStyle(uvIndex: number) {
            const {
                colour
            } = UV_INDEX.slice()
                .reverse()
                .find(({ start }) => uvIndex >= start);

            return {
                backgroundColor: colour
            };
        }

        return {
            days,
            getIcon,
            getDate,
            getPrecipIconStyle,
            getMinMax,
            getUvIndexDotStyle
        };
    }

});
</script>

<style lang="scss">

    .forecast-daily {

    }

    .forecast-daily__days {
        margin: calc(var(--spacing__x-small) * -1);

        & tr {

            & td:first-of-type {
                border-top-left-radius: var(--border__radius);
                border-bottom-left-radius: var(--border__radius);
            }

            & td:last-of-type {
                border-top-right-radius: var(--border__radius);
                border-bottom-right-radius: var(--border__radius);
            }
        }
    }

    .forecast-daily__precip-icon {
        display: block;
        width: 1em;
        height: 1em;
        margin-left: var(--spacing__xx-small);
        fill: var(--colour__primary);
    }

    .forecast-daily__day-column {
        padding: var(--spacing__x-small);
        text-align: center;
        vertical-align: middle;
    }

    .forecast-daily__day-column--label {
        width: 100%;
        text-align: left;
    }

    .forecast-daily__day-column--precip {
        text-align: right;
    }

    .forecast-daily__day-column--min {
        color: var(--font__colour--meta);
    }

    .forecast-daily__day-body {

        & td {
            padding: 0;
        }

        & .accordion-pane {
            width: 100%;
            overflow: hidden;
        }       
    }

    .forecast-daily__day-details {
        padding: var(--spacing__small) var(--spacing__x-small) var(--spacing__large);
    }

    .forecast-daily__day-observation {
        font-size: var(--font__size--small);
    }

</style>

================================================
FILE: client/src/components/forecast/hourly-forecast.vue
================================================
<template>
    <div class="forecast-hourly">
        <div class="forecast-hourly__header" layout="row center-justify">
            <div self="size-x1">{{ trend.label }} ({{ trend.unitOfMeasure }})</div>
            <div class="forecast-hourly__options" layout="row center-center">
                <icon-button class="menu-item text--small"
                    v-for="trend in trends"
                    v-tooltip="trend.label"
                    layout="vertical"
                    :key="trend.key"
                    :icon="trend.icon"
                    :class="getOptionClass(trend.key)"
                    @click="setTrend(trend.key)">
                </icon-button>
            </div>
        </div>
        <trends :data="hours" :options="trend.chartOptions" :key-by="hour => hour.dt.raw" :secondary-rows="2">
            <template #primary-column="column">
                <small>{{ getTime(column.value) }}</small>
            </template>
            <template #secondary-column="column">
                <template v-if="type === 'wind'">
                    <icon name="arrow-up-line" :style="getWindIconStyle(column.value)" v-if="column.row === 1"/>
                    <small class="text--x-small" v-else>{{ column.value.windDeg.formatted }}</small>
                </template>
                <template v-else>
                    <icon :name="getIcon(column.value.weather.id.raw, column.value.dt.raw)" v-if="column.row === 1"/>
                    <small class="text--x-small" v-else>{{ column.value.weather.description.formatted }}</small>
                </template>
            </template>
        </trends>
    </div>
</template>

<script lang="ts">
import TREND from '../../enums/forecast/trend';
import TRENDS from '../../constants/forecast/trends';

import Trends from '../charts/trends.vue';

import getIcon from '../../helpers/get-icon';

import {
    defineComponent,
    ref,
    computed
} from 'vue';

import {
    forecast,
    format,
    unitOfMeasure
} from '../../store';

import type {
    Formatted,
    IMappedForecastHour
} from '../../types/state';

export default defineComponent({
    
    components: {
        Trends
    },

    setup(props) {
        const type = ref(TREND.temperature);

        const trends = Object.keys(TRENDS).map(key => {
            const {
                icon,
                label
            } = TRENDS[key];

            return {
                key,
                icon,
                label
            };
        })

        const hours = computed(() => forecast.value.hourly);

        const trend = computed(() => {
            const trend = TRENDS[type.value];
            const uom = unitOfMeasure.value[trend.observation];

            return {
                ...trend,
                unitOfMeasure: uom
            };
        });

        function getTime(hour: Formatted<IMappedForecastHour>): string {
            return format.value.time(hour.dt.formatted as any, 'h a');
        }

        function getWindIconStyle(hour: Formatted<IMappedForecastHour>) {
            return {
                transformOrigin: 'center',
                transform: `rotate(${hour.windDeg.raw}deg)`
            };
        }

        function getOptionClass(key: TREND): string {
            return key === type.value && 'menu-item--active';
        }

        function setTrend(value: TREND): void {
            type.value = value;
        }

        return {
            type,
            hours,
            trends,
            trend,
            getTime,
            getIcon,
            getWindIconStyle,
            getOptionClass,
            setTrend
        };
    }

});
</script>

<style lang="scss">
    @import "~@ocula/style/src/_mixins.scss";

    .forecast-hourly__header {
        padding: 0 var(--spacing__medium) 0 var(--spacing__large) ;
    }

    .forecast-hourly__options {
        width: auto;
    }

</style>

================================================
FILE: client/src/components/forecast/summary.vue
================================================
<template>
    <div class="forecast-summary">
        <div layout="row center-justify">
            <div>
                <div class="forecast-summary__temp">{{ forecast.current.temp.formatted }}</div>
                <div class="forecast-summary__feels-like">
                    <small>Feels like {{ forecast.current.feelsLike.formatted }}</small>
                </div>
                <div class="forecast-summary__description">
                    <small>{{ forecast.current.weather.description.formatted }}</small>
                </div>
            </div>
            <div>
                <img class="forecast-summary__figure" :src="getFigure(forecast.current.weather.id.raw)" :alt="forecast.current.weather.description.raw">
            </div>
        </div>
        <div class="forecast-summary__last-updated" v-if="lastUpdated">
            <small>Updated {{ lastUpdated }} ago</small>
        </div>
    </div>
</template>

<script lang="ts">
import getFigure from '../../helpers/get-figure';

import {
    defineComponent,
    ref
} from "vue";

import {
    state,
    forecast
} from '../../store';

import {
    useTimer
} from '@ocula/components';

import {
    dateFormatDistanceToNow
} from '@ocula/utilities';

export default defineComponent({
    
    setup() {
        const lastUpdated = ref('');

        useTimer(() => {
            if (state.lastUpdated) {
                lastUpdated.value = dateFormatDistanceToNow(state.lastUpdated);
            }
        }, 10000);

        return {
            lastUpdated,
            forecast,
            getFigure
        };
    }

});
</script>

<style lang="scss">
    @import "~@ocula/style/src/_mixins.scss";

    .forecast-summary__temp {
        margin-bottom: var(--spacing__small);
        font-size: 4rem;
        font-weight: var(--font__weight--medium);
        line-height: 1;
    }

    .forecast-summary__figure {
        width: 96px;
        height: 96px;
    }

    .forecast-summary__last-updated {
        margin-top: var(--spacing__large);
        opacity: 0.5;
    }

    @include breakpoint("lg") {
        
        .forecast-summary__figure {
            width: 192px;
            height: 192px;
        }

    }

</style>

================================================
FILE: client/src/components/forecast/tides.vue
================================================
<template>
    <div class="forecast-tides">
        <div class="forecast-tides__header" :grid="tides.extremes.length">
            <div class="forecast-tides__observation" v-for="entry in tides.extremes" :key="entry.dt.raw">
                <div class="text--meta">
                    <small>{{ entry.type.formatted }}</small>
                </div>
                <div>{{ getTime(entry) }}</div>
                <div>
                    <small>{{ entry.height.formatted }}</small>
                </div>
            </div>
        </div>
        <trends class="forecast-tides__trends" :data="tides.heights" :options="chartOptions" :key-by="entry => entry.dt.raw">
            <template #start-label="data">{{ getTime(data.value) }}</template>
            <template #primary-column="column">
                <small>{{ getTime(column.value) }}</small>
            </template>
            <template #end-label="data">{{ getTime(data.value) }}</template>
        </trends>
        <div class="forecast-tides__disclaimer">
            <small class="text--meta">All tide measurements are displayed in metres (m)</small>
        </div>
    </div>
</template>

<script lang="ts">
import TIDES_CHART_OPTIONS from '../../constants/forecast/tides-chart-options';

import Trends from '../charts/trends.vue';

import {
    computed,
    defineComponent
} from 'vue';

import {
    forecast,
    format
} from '../../store';

export default defineComponent({

    components: {
        Trends
    },
   
    setup(props) {
        const tides = computed(() => forecast.value.tides);

        function getTime(entry): string {
            return format.value.time(entry.dt.formatted as any, 'h a');
        }

        return {
            tides,
            getTime,
            chartOptions: TIDES_CHART_OPTIONS
        };
    }

});
</script>

<style lang="scss">

    .forecast-tides__header {
        padding: 0 var(--spacing__large);
    }
    
    .forecast-tides__observation {
        text-align: center;
    }

    .forecast-tides__trends {
        margin: var(--spacing__small) 0;
    }

    .forecast-tides__disclaimer {
        text-align: center;
    }

</style>

================================================
FILE: client/src/components/forecast/today.vue
================================================
<template>
    <transition-box-resize class="forecast-today" grid="3">
        <div class="forecast-today__observation" v-for="observation in observations" :key="observation.label">
            <div class="text--meta">
                <small>{{ observation.label }}</small>
            </div>
            <div>{{ observation.value }}</div>
        </div>
    </transition-box-resize>
    <div class="margin__top--small text--centre">
        <icon-button :icon="detailedViewButton.icon" v-tooltip="detailedViewButton.tooltip" @click="toggleDetailedView"></icon-button>
    </div>
</template>

<script lang="ts">
import Observation from '../weather/observation.vue';

import {
    defineComponent,
    ref,
    computed
} from 'vue';

import {
    forecast,
    format
} from '../../store';

import {
    numberRound
} from '@ocula/utilities';

export default defineComponent({

    components: {
        Observation
    },
    
    setup() {
        const showDetailedView = ref(false);

        const detailedViewButton = computed(() => ({
            icon: showDetailedView.value ? 'arrow-up-s-line' : 'arrow-down-s-line',
            tooltip: `Show ${showDetailedView.value ? 'less' : 'more'} detail`
        }));

        function toggleDetailedView() {
            showDetailedView.value = !showDetailedView.value;
        }

        const observations = computed(() => {
            const {
                today,
                current
            } = forecast.value;

            const output = [
                {
                    icon: 'temp-cold-line',
                    label: 'Temp',
                    value: `${numberRound(today.temp.min.raw)} / ${numberRound(today.temp.max.raw)}`
                },
                {
                    icon: 'contrast-drop-2-line',
                    label: 'Humidity',
                    value: current.humidity.formatted
                },
                {
                    icon: 'rainy-line',
                    label: 'Precipitation',
                    value: `${today.pop.formatted} chance`
                },
                {
                    icon: 'sun-line',
                    label: 'Sunrise',
                    value: format.value.time(today.sunrise.formatted as any)
                },
                {
                    icon: 'moon-line',
                    label: 'Sunset',
                    value: format.value.time(today.sunset.formatted as any)
                },
                {
                    icon: 'windy-line',
                    label: 'Wind',
                    value: `${current.windSpeed.formatted} ${current.windDeg.formatted}`
                },
                {
                    icon: 'swap-line',
                    label: 'Pressure',
                    value: current.pressure.formatted
                },
                {
                    icon: 'cloudy-line',
                    label: 'Cloud Cov.',
                    value: current.clouds.formatted
                },
                {
                    icon: 'eye-line',
                    label: 'Visibility',
                    value: current.visibility.formatted
                }
            ];

            if (showDetailedView.value) {
                return output;
            }

            return output.slice(0, 6);
        });

        return {
            observations,
            detailedViewButton,
            toggleDetailedView
        };
    }

});
</script>

<style lang="scss">

    .forecast-today__observation {
        text-align: center;
    }

</style>

================================================
FILE: client/src/components/forecast/uv-index.vue
================================================
<template>
    <div class="forecast-uv-index">
        <div class="forecast-uv-index__bar-wrapper">
            <div class="forecast-uv-index__bar" :style="barStyle">
                <div class="forecast-uv-index__marker" :data-value="markerValue" :style="markerStyle"></div>
            </div>
        </div>
        <div class="forecast-uv-index__legend" layout="rows center-center">
            <div class="forecast-uv-index__legend-key" layout="row center-left" v-for="key in legend" :key="key.id">
                <div class="dot margin__right--x-small" :style="{ background: key.colour }"></div>
                <small class="text--meta">{{ key.label }}</small>
            </div>
        </div>
    </div>
</template>

<script lang="ts">
import UVINDEX from '../../constants/forecast/uv-index';

import {
    defineComponent,
    computed
} from 'vue';

import {
    forecast
} from '../../store';

import {
    arrayJoinBy,
    numberMaxBy,
    numberRound,
    scaleContinuous
} from '@ocula/utilities';

const MIN = 0;
const MAX = numberMaxBy(UVINDEX, ({ start }) => start).start + 2;

const offsetScale = scaleContinuous([MIN, MAX], [0, 100]);

export default defineComponent({

    setup(props) {
        const gradient = arrayJoinBy(UVINDEX, ({ colour, start }) => `${colour} ${offsetScale(start)}%`);

        const barStyle = {
            background: `linear-gradient(to right, ${gradient})`
        };

        const markerValue = computed(() => forecast.value.current.uvi.formatted);

        const markerStyle = computed(() => ({
            left: `${numberRound(offsetScale(forecast.value.current.uvi.raw, true), 2)}%`
        }));

        return {
            barStyle,
            markerValue,
            markerStyle,
            legend: UVINDEX
        };
    }

});
</script>

<style lang="scss">

    .forecast-uv-index__bar-wrapper {
        padding: 2rem 0 var(--spacing__x-small) 0;
    }

    .forecast-uv-index__bar {
        position: relative;
        display: block;
        width: 100%;
        height: 0.5rem;
        border-radius: 0.25rem;
    }

    .forecast-uv-index__marker {
        position: absolute;
        top: 0;
        left: 0;
        width: 0.5em;
        height: 100%;
        border-radius: 50%;
        background-color: var(--background__colour--hover);
        transform: translateX(-50%);
        overflow: visible;
        transition: left var(--transition__timing--long) var(--transition__easing--default);

        &::before,
        &::after {
            position: absolute;
            display: block;
            content: '';
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
        }

        &::before {
            margin-bottom: 0.1rem;
            border-top: 0.5rem solid var(--background__colour--hover);
            border-left: 0.5rem solid transparent;
            border-right: 0.5rem solid transparent;
        }

        &::after {
            content: attr(data-value);
            margin-bottom: 0.6rem;
            padding: var(--spacing__xx-small) var(--spacing__x-small);
            font-size: var(--font__size--small);
            font-weight: var(--font__weight--heavy);
            background: var(--background__colour--hover);
            border-radius: var(--border__radius);
        }
    }

    .forecast-uv-index__legend-key {
        width: auto;
        margin: var(--spacing__xx-small) var(--spacing__x-small);
    }

</style>

================================================
FILE: client/src/components/layouts/settings.vue
================================================
<template>
    <div class="settings-layout">
        <div class="settings-layout__header" layout="row center-left">
            <router-link class="link--inherit" :to="backRoute" replace v-if="backRoute">
                <icon-button icon="arrow-left-line" class="margin__right--small"/>
            </router-link>
            <strong>{{ title }}</strong>
        </div>
        <div class="settings-layout__body">
            <slot></slot>
        </div>
    </div>
</template>

<script>
import {
    defineComponent
} from 'vue';

export default defineComponent({
   
   props: {

       title: {
           type: String,
           default: 'Settings'
       },

       backRoute: {
           type: Object
       }

   }

});
</script>

<style lang="scss">

    .settings-layout__header {
        padding: var(--spacing__large);
    }

    .settings-layout__body {
        padding-bottom: var(--spacing__large);
    }

</style>

================================================
FILE: client/src/components/layouts/weather.vue
================================================
<template>
    <div class="weather-layout">
        <slot v-if="hasLocationSet"></slot>
        <container v-else>
            <div class="weather-layout__empty-state">
                <img :src="logo" alt="Ocula">
                <h3>Welcome to Ocula</h3>
                <p>
                    Get started by setting a location.
                </p>
                <p>
                    You can set a location by either searching for a place you know or using your current GPS position.
                </p>
                <div layout="rows center-center">
                    <button class="button button--primary margin__all--x-small" @click="setLocation">Set location</button>
                </div>
            </div>
        </container>
    </div>
</template>

<script lang="ts">
import EVENTS from '../../constants/core/events';

import applicationController from '../../controllers/application';

import logo from '../../assets/images/logo/logo-192.svg';

import {
    defineComponent,
    computed,
    onMounted,
    onActivated,
    watch
} from 'vue';

import {
    state,
    update,
    setCurrentLocation
} from '../../store';

import {
    useSubscriber
} from '@ocula/components';

import {
    typeIsNil
} from '@ocula/utilities';

export default defineComponent({

    setup() {
        const hasLocationSet = computed(() => !typeIsNil(state.settings.location));

        const {
            setLocation
        } = applicationController;

        function updateForecast() {
            if (hasLocationSet.value) {
                update();
            }
        }

        onMounted(updateForecast);
        onActivated(updateForecast);
        
        watch(() => state.settings.location, updateForecast);

        useSubscriber(EVENTS.application.visible, updateForecast);
        
        return {
            logo,
            hasLocationSet,
            setLocation,
            setCurrentLocation
        };
    }

});
</script>

<style lang="scss">

    .weather-layout {
    }

    .weather-layout__empty-state {
        padding: var(--spacing__large);
        text-align: center;
    }

</style>

================================================
FILE: client/src/components/modals/location.vue
================================================
<template>
    <modal :id="id" class="location-modal" ref="modal" @open="reset">
        <search-box class="location-modal__search" placeholder="Search for a location..." :loading="loading" v-model="search" v-focus />
        <div class="menu margin__top--small">
            <div class="menu-item" layout="row center-left" @click="setCurrentLocation">
                <icon name="gps-line" class="margin__right--small"/>
                <div class="text--truncate" self="size-x1">Current Location</div>
            </div>
            <template v-if="query">
                <div class="menu-item" layout="row center-left" v-for="location in searchResults" :key="location.id" @click="addLocation(location, true)">
                    <icon name="map-pin-line" class="margin__right--small"/>
                    <div class="text--truncate" self="size-x1">{{ location.longName }}</div>
                </div>
            </template>
            <template v-else>
                <div class="menu-item" layout="row center-justify" v-for="location in locations" :key="location.id" @click="setLocation(location)">
                    <icon name="star-line" class="margin__right--small"/>
                    <div class="text--truncate" self="size-x1">{{ location.longName }}</div>
                    <div v-tooltip:left="'Remove Location'" @click.stop="removeLocation(location)">
                        <icon name="delete-bin-line" class="margin__left--small"/>
                    </div>
                </div>
            </template>
        </div>
    </modal>
</template>

<script lang="ts">
import MODALS from '../../constants/core/modals';

import {
    defineComponent,
    ref,
    computed
} from 'vue';

import {
    state,
    searchLocations,
    setLocation,
    setCurrentLocation,
    addLocation,
    removeLocation
} from '../../store';

import {
    functionDebounce
} from '@ocula/utilities';

import type {
    ILocation
} from '../../types/location';

export default defineComponent({

    setup() {
        const id = MODALS.locations;

        const modal = ref(null);

        const query = ref('');
        const searchResults = ref<ILocation[]>([]);
        const loading = ref(false);

        const locations = computed(() => state.settings.locations);

        const executeSearch = functionDebounce(async function(query) {
            loading.value = true;

            try {
                searchResults.value = await searchLocations(query);
            } finally {
                loading.value = false;
            }
        }, 500);

        const search = computed({
            get: () => query.value,
            set: value => {
                query.value = value;

                if (value && value.length > 0) {
                    executeSearch(value);
                }
            }
        });

        function closeInvoke(callback) {
            return (...args) => {
                try {
                    callback(...args);
                } finally {
                    modal.value.close();
                }
            };
        }

        function reset() {
            query.value = '';
            searchResults.value = [];
        }

        return {
            id,
            modal,
            query,
            loading,
            search,
            reset,
            locations,
            searchResults,
            removeLocation,
            addLocation: closeInvoke(addLocation),
            setLocation: closeInvoke(setLocation),
            setCurrentLocation: closeInvoke(setCurrentLocation)
        };
    }

});
</script>

<style lang="scss">

    .location-modal {

        & .modal__body {
            padding: var(--spacing__small);
        }
    }

    .location-modal__search {
        width: 100%;
        border: none;
    }

</style>

================================================
FILE: client/src/components/settings/settings-item.vue
================================================
<template>
    <div class="settings-item" layout="row center-justify">
        <div class="settings-item__label">
            <slot name="label">{{ label }}</slot>
        </div>
        <div class="settings-item__value text--meta">
            <slot name="value">{{ value }}</slot>
        </div>
        <slot></slot>
    </div>
</template>

<script lang="ts">
import {
    defineComponent
} from 'vue';

export default defineComponent({
    
    props: {

        label: {
            type: String
        },

        value: {
            type: null
        }

    }

});
</script>

<style lang="scss">

    .settings-item {
        position: relative;

        & select {
            position: absolute;
            display: block;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            opacity: 0;
        }
    }

</style>

================================================
FILE: client/src/components/weather/actions.vue
================================================
<template>
    <div class="weather-actions" :class="actionsClass" layout="row center-justify">
        <icon-button class="weather-actions__action weather-actions__action--location" icon="map-pin-line" v-tooltip:right="'Set location'" @click="setLocation">
            <div v-if="location">{{ location.shortName }}</div>
            <div v-else>Unknown</div>
        </icon-button>
        <div self="size-x1">
            <slot></slot>
        </div>
        <icon-button class="weather-actions__action weather-actions__action--update" icon="refresh-line" @click="update(true)" v-tooltip:left="'Update'"></icon-button>
    </div>
</template>

<script lang="ts">
import STATUS from '../../enums/core/status';

import applicationController from '../../controllers/application';

import {
    defineComponent,
    computed
} from 'vue';

import {
    state,
    update
} from '../../store';

export default defineComponent({
   
    setup() {
        const {
            setLocation
        } = applicationController;

        const location = computed(() => state.location);
        const actionsClass = computed(() => state.status === STATUS.loading && 'weather-actions--loading');

        return {
            location,
            setLocation,
            update,
            actionsClass
        };
    }

});
</script>

<style lang="scss">

    .weather-actions {
        padding: var(--spacing__small) var(--spacing__medium);
    }

    .weather-actions--loading {

        & .weather-actions__action {
            pointer-events: none;
            cursor: not-allowed;
        }

        & .weather-actions__action--update {

            & .icon {
                animation: rotate 750ms ease-out infinite;
            }
        }
    }

    @keyframes rotate {

        from {
            transform: rotate(0deg);
        }

        to {
            transform: rotate(360deg);
        }

    }

</style>


================================================
FILE: client/src/components/weather/observation.vue
================================================
<template>
    <div class="weather-observation" layout="row center-left">
        <icon class="margin__right--small" :name="icon" v-if="icon"/>
        <div class="text--truncate">
            <strong>
                <slot name="label">{{ label }}</slot>
            </strong>
            <div>
                <slot></slot>
            </div>
        </div>
    </div>
</template>

<script lang="ts">
import {
    defineComponent
} from 'vue';

export default defineComponent({
    
    props: {

        icon: {
            type: String
        },

        label: {
            type: String
        }

    }

});
</script>

<style lang="scss">

    .weather-observation {
        display: inline-flex;
        width: auto;
        overflow: hidden;
        padding: var(--spacing__x-small) var(--spacing__small);
        background: var(--background__colour--hover);
        border-radius: var(--border__radius);
    }

</style>

================================================
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<number, Migration>

================================================
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<number, string> {};

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<PHASE, IFigure>;

================================================
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<string, any> {
    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<T>(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<IMappedForecast>): 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<FORECAST_SECTION, IForecastSection>;

================================================
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<Formatted<IForecastTideHeight>>;

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<Formatted<IForecastHour>>;

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<TREND, ITrend>;

================================================
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<OBSERVATION, UNIT_OF_MEASURE>

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<UNITS, UnitOfMeasure>

================================================
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<IMappedForecast>, 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<MAP, IMap>;

================================================
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<MAP> {
        return componentsController.open(DRAWERS.maps);
    }

    async notify(title: string, options?: NotificationOptions): Promise<Notification> {
        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
================================================
<!DOCTYPE html>
<html lang="en">

<head>
    <title>Ocula</title>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="Description" content="Ocula - The free and open-source progressive weather app">
    <link href="https://fonts.googleapis.com/css?family=DM+Sans:400,500,700&display=swap" rel="preconnect">

    <% if (process.env.NODE_ENV === 'production') { %>
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; font-src fonts.googleapis.com; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.googletagmanager.com *.google-analytics.com; img-src 'self' data: blob: *.mapbox.com *.google-analytics.com; worker-src 'self' blob:; child-src 'self' blob:; connect-src 'self' sentry.io *.google-analytics.com *.mapbox.com tilecache.rainviewer.com tile.openweathermap.org">
    <script src="https://www.googletagmanager.com/gtag/js?id=<%= process.env.GA_TRACKING_ID %>" async></script>
    <script>
        window.dataLayer = window.dataLayer || [];

        window.gtag = function () {
            dataLayer.push(arguments);
        }

        gtag('js', new Date());
        gtag('config', '<%= process.env.GA_TRACKING_ID %>');
    </script>
    <% } %>

    <style>

        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: sans-serif;
            font-size: 16px;
            font-weight: 400;
            line-height: 1.5;
            color: #353539;
            background-color: #FFFFFF;
        }

        .pre-app {
            display: flex;
            width: 100%;
            height: 100%;
            align-items: center;
            justify-content: center;
        }
        
        .pre-app__body {
            width: auto;
            text-align: center;
        }

        .pre-app__logo {
            display: block;
        }

        .pre-app__title-block {
            margin-top: 2rem;
        }

        .pre-app__title {
            font-weight: bold;
        }

    </style>
</head>

<body>
    <noscript>Javascript must be enabled to run this app</noscript>
    <div class="pre-app">
        <div class="pre-app__body">
            <svg class="pre-app__logo" width="192" height="192" fill="none" xmlns="http://www.w3.org/2000/svg">
                <circle cx="97.5" cy="97.5" r="78.75" fill="url(#paint0_linear)" />
                <path opacity=".75"
                    d="M95.344 140.359c-25.647 0-48.74-24.584-48.74-49.41 0-24.825 28.466-43.465 54.113-43.465 25.646 0 38.762 18.64 38.762 43.466 0 24.825-18.488 49.409-44.135 49.409z"
                    fill="#fff" />
                <path opacity=".5"
                    d="M57.507 104.35C52.608 79.938 74.09 60.546 98.502 55.647c24.412-4.898 46.458 11.08 51.357 35.492 4.898 24.412-10.178 46.05-34.59 50.949-24.412 4.898-52.864-13.326-57.762-37.738z"
                    fill="#fff" />
                <g opacity=".5" filter="url(#filter0_d)">
                    <path
                        d="M98.215 54.872c24.686 2.444 44.495 29.089 42.051 53.775-2.444 24.686-31.678 40.508-56.364 38.064-24.686-2.444-35.475-22.228-33.032-46.914 2.444-24.686 22.66-47.369 47.345-44.925z"
                        fill="#fff" />
                </g>
                <defs>
                    <linearGradient id="paint0_linear" x1="97.5" y1="18.75" x2="97.5" y2="176.25"
                        gradientUnits="userSpaceOnUse">
                        <stop stop-color="#FDC830" />
                        <stop offset="1" stop-color="#F37335" />
                    </linearGradient>
                    <filter id="filter0_d" x="42.592" y="46.666" width="106.538" height="109.201"
                        filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
                        <feFlood flood-opacity="0" result="BackgroundImageFix" />
                        <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
                        <feOffset />
                        <feGaussianBlur stdDeviation="2" />
                        <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
                        <feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
                        <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
                    </filter>
                </defs>
            </svg>
            <div class="pre-app__title-block">
                <div class="pre-app__title">Ocula</div>
                <div class="pre-app__subtitle">Loading. Please wait.</div>
            </div>
        </div>
    </div>
</body>

</html>

================================================
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
================================================
<template>
    <div class="route error-index">
        <div class="text--centre">
            <h1>Error</h1>
            <h3>An error has occurred</h3>
        </div>
    </div>
</template>

================================================
FILE: client/src/routes/error/not-found.vue
================================================
<template>
    <div class="route error-not-found">
        <div class="text--centre">
            <h1>404</h1>
            <h3>Not Found</h3>
        </div>
    </div>
</template>

================================================
FILE: client/src/routes/error.vue
================================================
<template>
    <div class="route error-master">
        <router-view />
    </div>
</template>

================================================
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
================================================
<template>
    <div class="route forecast-index transition-theme-change" layout="column top-stretch" :class="theme.weather.class">
        <container class="forecast-index__header">
            <weather-actions></weather-actions>
            <header class="forecast-index__summary" layout="column center-stretch" v-if="forecast">
                <forecast-summary></forecast-summary>
            </header>
        </container>
        <div class="forecast-index__body" self="size-x1" v-if="forecast">
            <container class="forecast-index__container">
                <block class="forecast-index__block"
                    v-for="section in sections"
                    :key="section.id"
                    :class="section.class"
                    :title="section.label">
                    <component :is="section.component"/>
                </block>
            </container>
        </div>
    </div>
</template>

<script lang="ts">
import FORECAST_SECTIONS from '../../constants/forecast/sections';

import WeatherActions from '../../components/weather/actions.vue';
import ForecastSummary from '../../components/forecast/summary.vue';
import ForecastTides from '../../components/forecast/tides.vue';

import setThemeMeta from '../../helpers/set-theme-meta';

import {
    defineComponent,
    watch,
    computed
} from 'vue';

import {
    theme,
    state,
    forecast
} from '../../store';

export default defineComponent({

    components: {
        WeatherActions,
        ForecastSummary,
        ForecastTides
    },
    
    setup() {
        const sections = computed(() => {
            const visibleSections = state.settings.forecast.sections.filter(({ type,  visible }) => {
                const {
                    condition
                } = FORECAST_SECTIONS[type]; 

                return !!visible && (!condition || condition(forecast.value))
            });

            return visibleSections.map(({ type }) => ({
                id: type,
                class: `forecast-index__block--${type}`,
                ...FORECAST_SECTIONS[type]
            }));
        });

        watch(() => theme.value.weather, ({ colour }) => setThemeMeta(colour));

        return {
            theme,
            forecast,
            sections
        };
    }

});
</script>

<style lang="scss">
    @import "~@ocula/style/src/_mixins.scss";

    .forecast-index {
        color: var(--font__colour--weather);
        background: var(--background__colour--weather);
    }

    .forecast-index__summary {
        min-height: 30vh;
        padding: var(--spacing__large);
        padding-top: 0;
    }

    .forecast-index__body {
        padding-top: var(--spacing__small);
        color: var(--font__colour);
        background: var(--background__colour);
        border-top-left-radius: var(--border__radius--large);
        border-top-right-radius: var(--border__radius--large);
    }

    .forecast-index__block {
        
        &:not(:last-of-type) {
            margin-bottom: var(--spacing__small);
        }

        & .block__header,
        & .block__body {
            padding-left: var(--spacing__large);
            padding-right: var(--spacing__large);
        }
    }

    .forecast-index__block--hourly-forecast,
    .forecast-index__block--tides {

        & .block__body {
            padding-left: 0;
            padding-right: 0;
        }
    }

    @include breakpoint("lg") {

        .forecast-index__body {
            border-radius: 0;
        }

    }

</style>

================================================
FILE: client/src/routes/forecast.vue
================================================
<template>
    <weather-layout class="route forecast-master">
        <router-view />
    </weather-layout>    
</template>

<script lang="ts">
import WeatherLayout from '../components/layouts/weather.vue';

import {
    defineComponent
} from 'vue';

export default defineComponent({

    components: {
        WeatherLayout
    }

});
</script>

================================================
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
================================================
<template>
    <div class="route maps-index" layout="column top-stretch">
        <div>
            <container>
                <weather-actions></weather-actions>
            </container>
            <div class="maps-index__options">
                <container class="maps-index__options-container" layout="row center-justify">
                    <icon-button class="maps-index__drawer-button" self="size-x1" :icon="map.icon" @click.native="changeMap">
                        <div layout="row center-justify">
                            <div>{{ map.label }}</div>
                            <loader v-if="status.loading"/>
                        </div>
                    </icon-button>
                    <icon-button icon="focus-3-line" v-tooltip:left="'Recentre'" @click.native="recentre"></icon-button>
                </container>
            </div>
        </div>
        <div class="maps-index__body" layout="column justify-stretch" self="size-x1">
            <div self="size-x1">
                <maps-drawer class="maps-index__drawer"/>
                <mapbox-map class="maps-index__map"
                    ref="mapboxMap"
                    v-if="forecast"
                    :key="theme.core.mapStyle"
                    :latitude="forecast.lat.raw"
                    :longitude="forecast.lon.raw"
                    :zoom="settings.maps.zoom"
                    :pitch="settings.maps.pitch"
                    :style="theme.core.mapStyle"
                    @movestart="onMoveStart"
                    @moveend="onMoveEnd"
                    @idle="onIdle"
                    @sourcedataloading="onSourceDataLoading">
                    <mapbox-legend v-if="map.legend">
                        <div class="maps-index__legend">
                            <template v-for="key in map.legend" :key="key.colour">
                                <div class="maps-index__legend-colour" :style="{ background: key.colour }"></div>
                                <div class="maps-index__legend-label">{{ key.label }}</div>
                            </template>
                        </div>
                    </mapbox-legend>
                    <mapbox-raster-layer v-for="layer in layers"
                        class="maps-index__map-layer"
                        :key="layer.id"
                        :id="layer.id"
                        :tiles="[layer.url]"
                        :layout="layer.layout"
                        :paint="layer.paint"/>
                </mapbox-map>
            </div>
            <div class="maps-index__controls" v-if="canPlay">
                <container class="maps-index__controls-container" layout="row center-justify">
                    <icon-button class="maps-index__control-loop" :icon="controlIcon" @click.native="togglePlaying"></icon-button>
                    <div class="padding__horizontal--small" self="size-x1">
                        <input class="maps-index__control-slider"
                            v-model.number="layerIndex"
                            type="range"
                            min="0"
                            step="1"
                            :max="layers.length - 1"
                            :disabled="status.loading">
                    </div>
                    <div class="maps-index__control-label">{{ layer.label }}</div>
                </container>
            </div>
        </div>
    </div>
</template>

<script lang="ts">
import MAP from '../../enums/maps/map';
import MAPS from '../../constants/maps/maps';

import WeatherActions from '../../components/weather/actions.vue';
import MapsDrawer from '../../components/drawers/maps.vue';

import applicationController from '../../controllers/application';

import {
    defineComponent,
    ref,
    computed,
    PropType,
    watch,
    reactive
} from 'vue';

import {
    state,
    theme,
    forecast,
    format
} from '../../store';

import {
    typeIsFunction,
    numberClamp
} from '@ocula/utilities';

export default defineComponent({

    components: {
        WeatherActions,
        MapsDrawer
    },

    props: {

        type: {
            type: String as PropType<MAP>
        }

    },
    
    setup(props) {
        const mapboxMap = ref(null);

        let moveHandle = null;
        let intervalHandle = null;
        let resumePlaying = false;

        const status = reactive({
            loading: false,
            playing: false,
        });

        let layerIndex = ref(0);

        const settings = computed(() => state.settings);

        const map = computed(() => {
            let {
                layers,
                ...other
            } = MAPS[props.type || state.settings.maps.default];

            if (typeIsFunction(layers) && forecast.value) {
                layers = layers(forecast.value, format.value);
            }

            clearMoveHandle();
            stopPlaying();
            layerIndex.value = layers.length - 1;

            return {
                ...other,
                layers
            };
        });

        const layers = computed(() => {
            let {
                layers
            } = map.value;

            return layers.map((layer, index) => {
                let visibility = index === layerIndex.value ? 'visible' : 'none';
                let opacity = index === layerIndex.value ? 0.75 : 0;

                if (status.playing) {
                    visibility = 'visible';
                }

                return {
                    ...layer,
                    layout: {
                        visibility
                    },
                    paint: {
                        'raster-opacity': opacity
                    }
                };
            });
        });

        const layer = computed(() => map.value.layers[layerIndex.value]);
        const controlIcon = computed(() => status.playing ? 'stop-fill' : 'play-fill');
        const canPlay = computed(() => layers.value.length > 1);

        function recentre() {
            mapboxMap.value.updateLocation();
        }

        function onIdle() {
            status.loading = false;
        }

        function onSourceDataLoading() {
            status.loading = true;
        }

        function clearMoveHandle() {
            if (moveHandle) {
                window.clearTimeout(moveHandle);
                moveHandle = null;
            }
        }

        function runLoop() {
            intervalHandle = window.setInterval(() => {
                layerIndex.value = layerIndex.value === layers.value.length - 1 ? 0 : layerIndex.value + 1;
            }, state.settings.maps.framerate);
        }

        function startPlaying() {
            if (!mapboxMap.value) {
                return;
            }

            status.playing = true;

            mapboxMap.value.once('idle', runLoop);
        }

        function stopPlaying() {
            if (!mapboxMap.value) {
                return;
            }

            status.playing = false;

            if (intervalHandle) {
                window.clearInterval(intervalHandle);
                intervalHandle = null;
            }

            mapboxMap.value.off('idle', runLoop);
        }

        function togglePlaying() {
            if (status.playing) {
                return stopPlaying();
            }

            startPlaying();
        }

        function onMoveStart(event, map) {
            resumePlaying = status.playing;

            clearMoveHandle();
            stopPlaying();
        }

        function onMoveEnd(event, map) {
            if (resumePlaying && canPlay.value) {
                moveHandle = window.setTimeout(startPlaying, 1000);
            }
        }

        return {
            theme,
            forecast,
            settings,
            map,
            status,
            layers,
            layer,
            layerIndex,
            mapboxMap,
            recentre,
            controlIcon,
            onIdle,
            onSourceDataLoading,
            onMoveStart,
            onMoveEnd,
            canPlay,
            togglePlaying,
            changeMap: applicationController.setMapType
        };
    }

});
</script>

<style lang="scss">

    .maps-index__options {
        border-top: 1px solid var(--border__colour);
        border-bottom: 1px solid var(--border__colour);
    }

    .maps-index__options-container,
    .maps-index__controls-container {
        padding: var(--spacing__x-small) var(--spacing__medium);
    }

    .maps-index__body {
        position: relative;
    }

    .maps-index__legend {
        display: grid;
        grid-template-columns: auto max-content;
        gap: var(--spacing__xx-small) var(--spacing__x-small);
        align-items: center;
    }

    .maps-index__legend-colour {
        width: 1em;
        height: 1em;
        border-radius: 2px;
    }

    .maps-index__drawer {
        position: absolute;
        z-index: 1000;
    }

    .maps-index__controls {
        border-top: 1px solid var(--border__colour);
    }

    .maps-index__control-slider {
        display: block;
        width: 100%;
    }

</style>

================================================
FILE: client/src/routes/maps.vue
================================================
<template>
    <weather-layout class="route maps-master">
        <router-view />
    </weather-layout>    
</template>

<script lang="ts">
import WeatherLayout from '../components/layouts/weather.vue';

import {
    defineComponent
} from 'vue';

export default defineComponent({

    components: {
        WeatherLayout
    }

});
</script>

================================================
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
================================================
<template>
    <settings-layout class="route settings-locations" title="Locations" :back-route="backRoute">
        <div class="settings-locations__locations menu">
            <div class="menu-item" layout="row center-justify" v-for="location in locations" :key="location.id">
                <div class="text--truncate" self="size-x1">{{ location.longName }}</div>
                <div v-tooltip:left="'Remove Location'" @click.stop="removeLocation(location)">
                    <icon name="delete-bin-line" class="margin__left--small"/>
                </div>
            </div>
        </div>
    </settings-layout>
</template>

<script lang="ts">
import ROUTES from '../../../constants/core/routes';

import SettingsLayout from '../../../components/layouts/settings.vue';

import {
    defineComponent,
    computed
} from 'vue';

import {
    state,
    removeLocation
} from '../../../store';

export default defineComponent({
    
    components: {
        SettingsLayout
    },

    setup() {
        const backRoute = {
            name: ROUTES.settings.index
        };

        const locations = computed(() => state.settings.locations);

        return {
            backRoute,
            locations,
            removeLocation
        };
    }

});
</script>

<style lang="scss">

    .settings-locations__locations {
        padding: 0 var(--spacing__small);
    }

</style>

================================================
FILE: client/src/routes/settings/forecast/sections.vue
================================================
<template>
    <settings-layout class="route settings-sections" title="Sections" :back-route="backRoute">
        <transition-group tag="div" name="sections" class="settings-sections__sections menu">
            <div class="settings-sections__section menu-item" layout="row center-justify" v-for="(section, index) in sections" :key="section.type">
                <div class="text--truncate margin__right--x-small" self="size-x1">{{ section.label }}</div>
                <icon-button icon="arrow-up-line" @click.native.stop="moveSection(section.type, -1)" v-visible="index > 0"></icon-button>
                <icon-button icon="arrow-down-line" @click.native.stop="moveSection(section.type, 1)" v-visible="index < sections.length - 1"></icon-button>
                <icon-button :icon="getVisibilityIcon(section)" @click.native.stop="setSectionVisibility(section.type, !section.visible)"></icon-button>
            </div>
        </transition-group>
    </settings-layout>
</template>

<script lang="ts">
import ROUTES from '../../../constants/core/routes';
import FORECAST_SECTIONS from '../../../constants/forecast/sections';

import SettingsLayout from '../../../components/layouts/settings.vue';

import {
    defineComponent,
    computed
} from 'vue';

import {
    state,
    moveSection,
    setSectionVisibility
} from '../../../store';

export default defineComponent({
    
    components: {
        SettingsLayout
    },

    setup() {
        const backRoute = {
            name: ROUTES.settings.index
        };

        const sections = computed(() => {
            return state.settings.forecast.sections.map(section => {
                const {
                    label
                } = FORECAST_SECTIONS[section.type];

                return {
                    ...section,
                    label
                };
            })
        });

        function getVisibilityIcon(section): string {
            return section.visible ? 'eye-line' : 'eye-off-line';
        }

        return {
            backRoute,
            sections,
            moveSection,
            getVisibilityIcon,
            setSectionVisibility
        };
    }

});
</script>

<style lang="scss">

    .settings-sections__sections {
        padding: 0 var(--spacing__small);
    }

    .settings-sections__section {
        padding: var(--spacing__x-small) var(--spacing__small);
        cursor: default;
    }

    .sections-move {
        transition: transform var(--transition__timing) var(--transition__easing--default);
    }

</style>

================================================
FILE: client/src/routes/settings/general/about.vue
================================================
<template>
    <settings-layout class="route settings-about" title="About" :back-route="backRoute">
        <div class="settings-about__body">
            <div class="settings-about__header">
                <img class="settings-about__logo" :src="logo" alt="Ocula">
                <h1 class="settings-about__title">{{ manifest.title }}</h1>
                <div class="settings-about__subtitle">{{ manifest.description }}</div>
            </div>
            <block class="settings-about__block" title="Details">
                <div class="settings-about__details-grid">
                    <strong>Version</strong>
                    <div>{{ manifest.version }}</div>
                    <strong>Author</strong>
                    <div>{{ manifest.author }}</div>
                    <strong>Licence</strong>
                    <div>{{ manifest.license }}</div>
                    <strong>Source</strong>
                    <a :href="manifest.repository" target="_blank">Github</a>
                </div>
            </block>
            <block class="settings-about__block" title="Attribution">
                <div class="settings-about__details-grid">
                    <strong>Forecast Data</strong>
                    <a href="https://openweathermap.org" target="_blank">Open Weather Maps</a>
                    <strong>Tide Data</strong>
                    <a href="https://www.worldtides.info" target="_blank">World Tides</a>
                    <strong>Maps/Geocoding</strong>
                    <a href="https://www.mapbox.com" target="_blank">Mapbox</a>
                    <strong>Radar Imagery</strong>
                    <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>
                    <strong>Logo Design</strong>
                    <a href="https://github.com/ethanroxburgh" target="_blank">Ethan Roxburgh</a>
                    <strong>Icons</strong>
                    <a href="https://remixicon.com" target="_blank">Remix Icons</a>
                </div>
            </block>
            <block class="settings-about__block" title="Sponsors">
                <div>Kerry Tarrant</div>
            </block>
            <div class="margin__top--large text--centre">
                <small class="text--meta">Psalm 147:8</small>
            </div>
        </div>
    </settings-layout>   
</template>

<script lang="ts">
import ROUTES from '../../../constants/core/routes';

import SettingsLayout from '../../../components/layouts/settings.vue';

import manifest from '../../../../package.json';
import logo from '../../../assets/images/logo/logo-192.svg';

import {
    defineComponent
} from 'vue';

export default defineComponent({

    components: {
        SettingsLayout
    },

    setup() {
        const backRoute = {
            name: ROUTES.settings.index
        };

        return {
            backRoute,
            logo,
            manifest
        };
    }

});
</script>

<style lang="scss">

    .settings-about__body {
        padding: 0 var(--spacing__small);
    }

    .settings-about__header {
        text-align: center;
        margin-bottom: var(--spacing__large);
    }

    .settings-about__title {
        text-transform: uppercase;
        letter-spacing: 0.2em;
    }

    .settings-about__logo {
        width: 96px;
    }

    .settings-about__block {
        
        &:not(:last-of-type) {
            margin-bottom: var(--spacing__small);
        }

        & .block__title {
            color: var(--font__colour--meta);
        }
    }

    .settings-about__details-grid {
        display: grid;
        grid-template-columns: auto 1fr;
        grid-gap: var(--spacing__x-small) var(--spacing__small);
    }

</style>

================================================
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
================================================
<template>
    <settings-layout class="route settings-themes" title="Themes" :back-route="backRoute">
        <div class="settings-themes__themes" grid="2 md-4 lg-4">
            <div class="settings-themes__theme"
                layout="row center-center"
                v-for="(value, key) in themes"
                :key="key"
                :class="value.class"
                @click="setTheme(value.id)">
                <div>{{ value.name }}</div>
                <div class="settings-themes__theme-check" v-show="isCurrentTheme(value.id)">
                    <small class="text--x-small">
                        <icon name="check-line"/>
                    </small>
                </div>
            </div>
        </div>
    </settings-layout>
</template>

<script lang="ts">
import ROUTES from '../../../constants/core/routes';

import SettingsLayout from '../../../components/layouts/settings.vue';

import {
    defineComponent,
    computed
} from 'vue';

import {
    state,
    theme,
    updateSettings
} from '../../../store';

import {
    core as themes
} from '../../../themes';

export default defineComponent({

    components: {
        SettingsLayout
    },
    
    setup() {
        const backRoute = {
            name: ROUTES.settings.index
        };

        const theme = computed(() => state.settings.theme);

        function isCurrentTheme(id: string) {
            return id === theme.value;
        }

        function setTheme(id: string): void {
            updateSettings({
                theme: id
            });
        }

        return {
            backRoute,
            theme,
            themes,
            isCurrentTheme,
            setTheme
        };
    }

});
</script>

<style lang="scss">

    .settings-themes__themes {
        padding: 0 var(--spacing__large);
    }

    .settings-themes__theme {
        position: relative;
        color: var(--font__colour);
        background-color: var(--background__colour);
        border: 1px solid var(--border__colour);
        border-radius: var(--border__radius);
        overflow: hidden;

        &::after {
            display: block;
            content: '';
            padding-bottom: 100%;
        }
    }

    .settings-themes__theme-check {
        position: absolute;
        top: var(--spacing__x-small);
        right: var(--spacing__x-small);
        padding: var(--spacing__xx-small);
        color: var(--font__colour--compliment);
        background-color: var(--colour__primary);
        border-radius: 50%;

        & .icon {
            display: block;
        }
    }

</style>

================================================
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
================================================
<template>
    <settings-layout class="route settings-index">
        <div class="settings-index__body">
            <block class="settings-index__block" title="Forecast">
                <div class="menu">
                    <settings-item class="menu-item" label="Units" :value="unit.label">
                        <select name="units" v-model="units">
                            <option v-for="(value, key) in unitOptions" :key="key" :value="key">{{ value.label }}</option>
                        </select>
                    </settings-item>
                    <router-link class="link--inherit" :to="routes.forecast.locations">
                        <settings-item class="menu-item" label="Locations" :value="locationsLabel"></settings-item>
                    </router-link>
                    <router-link class="link--inherit" :to="routes.forecast.sections">
                        <settings-item class="menu-item" label="Sections"></settings-item>
                    </router-link>
                </div>
            </block>
            <block class="settings-index__block" title="Maps">
                <div class="menu">
                    <settings-item class="menu-item" label="Default Map" :value="map.label">
                        <select name="default-map" v-model="defaultMap">
                            <option v-for="(value, key) in mapOptions" :key="key" :value="key">{{ value.label }}</option>
                        </select>
                    </settings-item>
                    <router-link class="link--inherit" :to="routes.maps.display">
                        <settings-item class="menu-item" label="Display"></settings-item>
                    </router-link>
                    <settings-item class="menu-item" label="Framerate" :value="framerate">
                        <select name="framerate" v-model="framerate">
                            <option v-for="value in framerates" :key="value" :value="value">{{ value }}ms</option>
                        </select>
                    </settings-item>
                </div>
            </block>
            <block class="settings-index__block" title="General">
                <div class="menu">
                    <router-link class="link--inherit" :to="routes.general.theme">
                        <settings-item class="menu-item" label="Theme" :value="theme.core.name"></settings-item>
                    </router-link>
                    <settings-item class="menu-item" label="Update" @click.native="updateApplication">
                        <template #value>
                            <loader v-if="updating"></loader>
                        </template>
                    </settings-item>
                    <settings-item class="menu-item" label="Reset" @click.native="reset"></settings-item>
                    <router-link class="link--inherit" :to="routes.general.about">
                        <settings-item class="menu-item" label="About"></settings-item>
                    </router-link>
                </div>
            </block>
        </div>
    </settings-layout>
</template>

<script lang="ts">
import UNITS from '../../constants/forecast/units';
import ROUTES from '../../constants/core/routes';
import MAPS from '../../constants/maps/maps';

import SettingsLayout from '../../components/layouts/settings.vue';
import SettingsItem from '../../components/settings/settings-item.vue';

import {
    defineComponent,
    ref,
    computed
} from 'vue';

import {
    state,
    theme,
    updateSettings,
    resetSettings,
    update
} from '../../store';

import {
    core as themeOptions
} from '../../themes';

import {
    componentsController
} from '@ocula/components';

export default defineComponent({

    components: {
        SettingsLayout,
        SettingsItem
    },
    
    setup() {
        const updating = ref(false);

        const routes = {
            forecast: {
                locations: {
                    name: ROUTES.settings.forecast.locations
                },
                sections: {
                    name: ROUTES.settings.forecast.sections
                }
            },
            maps: {
                display: {
                    name: ROUTES.settings.maps.display
                }
            },
            general: {
                theme: {
                    name: ROUTES.settings.general.theme
                },
                about: {
                    name: ROUTES.settings.general.about
                }
            }
        };

        const units = computed({
            get: () => state.settings.units,
            set: units => {
                updateSettings({ units });
                update(true);
            }
        });

        const defaultMap = computed({
            get: () => state.settings.maps.default,
            set: value => updateSettings({
                maps: {
                    ...state.settings.maps, 
                    default: value
                }
            })
        });

        const framerate = computed({
            get: () => state.settings.maps.framerate,
            set: value => updateSettings({
                maps: {
                    ...state.settings.maps, 
                    framerate: value
                }
            })
        });

        const framerates = Array.from({ length: 6 }, (_, index) => ++index * 500);
        
        const unit = computed(() => UNITS[state.settings.units]);
        const map = computed(() => MAPS[state.settings.maps.default]);

        const locationsLabel = computed(() => `${state.settings.locations.length} saved`);

        async function updateApplication() {
            const registration = await navigator.serviceWorker.getRegistration();

            if (!registration) {
                return;
            }

            try {
                updating.value = true;

                await registration.update();
            } finally {
                updating.value = false;
            }
        }

        async function reset() {
            try {
                await componentsController.confirm({
                    message: 'This will reset all settings back to default. This cannot be undone. Do you wish to continue?',
                    confirmLabel: 'Yes, Reset'
                });

                resetSettings();
            } catch {
                // do nothing
            }
        }

        return {
            routes,
            units,
            unit,
            defaultMap,
            framerate,
            framerates,
            map,
            locationsLabel,
            theme,
            themeOptions,
            mapOptions: MAPS,
            unitOptions: UNITS,
            updateApplication,
            updating,
            reset
        };
    }

});
</script>

<style lang="scss">

    .settings-index__header {
        padding: var(--spacing__large);
    }

    .settings-index__body {
        padding: 0 var(--spacing__small);
    }

    .settings-index__block {

        &:not(:last-of-type) {
            margin-bottom: var(--spacing__small);
        }

        & .block__title {
            color: var(--font__colour--meta);
        }

        & .block__body {
            padding: var(--spacing__small) 0;
        }
    }

</style>

================================================
FILE: client/src/routes/settings/maps/display.vue
================================================
<template>
    <settings-layout class="route settings-maps-display" title="Display" :back-route="backRoute">
        <div class="settings-maps-display__options">
            <block title="Zoom">
                <template #secondary>{{ zoom }}</template>
                <input class="settings-maps-display__slider" type="range" min="1" max="12" step="1" v-model.number="zoom">
            </block>
            <block class="margin__top--large" title="Pitch">
                <template #secondary>{{ pitch }} &deg;</template>
                <input class="settings-maps-display__slider" type="range" min="0" max="85" step="5" v-model.number="pitch">
            </block>
            <block class="margin__top--large" title="Preview">
                <mapbox-map class="settings-maps-display__map"
                    :latitude="location.latitude"
                    :longitude="location.longitude"
                    :style="theme.core.mapStyle"
                    :zoom="zoom"
                    :pitch="pitch"
                    :interactive="false">
                </mapbox-map>
            </block>
        </div>
    </settings-layout>
</template>

<script lang="ts">
import ROUTES from '../../../constants/core/routes';

import SettingsLayout from '../../../components/layouts/settings.vue';

import {
    defineComponent,
    ref,
    computed
} from 'vue';

import {
    state,
    theme,
    updateSettings
} from '../../../store';

import {
    functionDebounce
} from '@ocula/utilities';

import type {
    IMapSettings
} from '../../../types/storage';

export default defineComponent({
    
    components: {
        SettingsLayout
    },

    setup() {
        const backRoute = {
            name: ROUTES.settings.index
        };

        // Sydney, Australia
        const location = {
            latitude: -33.8688,
            longitude: 151.2093
        }

        function getMapsComputed(key: keyof IMapSettings) {
            return {
                get: () => state.settings.maps[key],
                set: functionDebounce(value => {
                    updateSettings({
                        maps: {
                            ...state.settings.maps,
                            [key]: value
                        }
                    })
                }, 100)
            }
        }

        const zoom = computed(getMapsComputed('zoom'));
        const pitch = computed(getMapsComputed('pitch'));

        return {
            backRoute,
            location,
            theme,
            zoom,
            pitch
        };
    }

});
</script>

<style lang="scss">

    .settings-maps-display__options {
        padding: 0 var(--spacing__large);
    }

    .settings-maps-display__slider {
        display: block;
        width: 100%;
    }

    .settings-maps-display__map {
        width: 100%;
        height: 33vh;
        border: 1px solid var(--border__colour);
        border-radius: var(--border__radius);
        overflow: hidden;
    }

</style>

================================================
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
================================================
<template>
    <div class="route settings-master">
        <container class="settings-master__container">
            <router-view />
        </container>
    </div>
</template>

<script lang="ts">
import {
    defineComponent
} from 'vue';

export default defineComponent({

});
</script>

<style lang="scss">

</style>

================================================
FILE: client/src/services/location.ts
================================================
import {
    ILocation
} from '../types/location';

export async function searchLocations(query: string): Promise<ILocation[]> {
    const response = await fetch(`/api/location/search?query=${query}`);

    return response.json();
}

export async function getLocation(latitude: number, longitude: number): Promise<ILocation> {  
    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<IForecast> {
    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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 
    <url>
        <loc>https://app.ocula.io</loc>
        <priority>1.0</priority>
    </url>
    <url>
        <loc>https://app.ocula.io/maps</loc>
        <priority>0.8</priority>
    </url>
</urlset>

================================================
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<ILocation> {
    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<ILocation[]> {
    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<ISettings>): 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<Formatted<IMappedForecast>>(state => {
    const {
        forecast,
        settings
    } = state;

    if (!forecast) {
        return;
    }

    const format = FORMATS[settings.units] || FORMATS[UNITS.metric];

    const {
        daily,
        ...other
    } = objectTransform<IForecast, Formatted<IMappedForecast>>(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<IFormatter>(({ 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<ICoordinate> {
    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<ISettings>) {
    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<T, U = string> =  {
    [P in keyof T]: T[P] extends string | number ? {
        raw: T[P];
        formatted: U
    } : Formatted<T[P]>
}

export interface IState {
    status: STATUS;
    lastUpdated: Date;
    settings: ISettings;
    location: ILocation;
    forecast: IForecast;
};

export interface IMappedForecastCurrent extends Omit<IForecastCurrent, 'weather'> {
    weather: IForecastWeather
};

export interface IMappedForecastDay extends Omit<IForecastDay, 'weather'> {
    weather: IForecastWeather
};

export interface IMappedForecastHour extends Omit<IForecastHour, 'weather'> {
    weather: IForecastWeather
};

export interface IMappedForecast extends Omit<IForecast, 'current' | 'daily' | 'hourly'> {
    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<string, string>;
    class: string | string[];
    mapStyle: string | Record<string, string>;
}

================================================
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<string, number>;
    snow?: Record<string, number>;
}

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<string, number>;
    snow?: Record<string, number>;
}

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<string, number>;
    snow?: Record<string, number>;
}

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<T extends IChartOptions = IChartOptions> {

    protected id: string;
    protected element: Element;
    protected options: T;
    protected rendering: boolean;

    protected width: number;
    protected height: number;

    protected svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
    protected canvas: d3.Selection<SVGGElement, unknown, null, undefined>;

    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<ILinePoint>()
    .defined(data => !!data.y1)
    .x(data => data.x)
    .y(data => data.y1);

const areaGenerator = d3.area<ILinePoint>()
    .defined(data => !!data.y1)
    .x(data => data.x)
    .y0(data => data.y0)
    .y1(data => data.y1);

export default class LineChart extends Chart<ILineOptions> {

    private lineGroup: d3.Selection<SVGGElement, ILinePoint[], null, undefined>;
    private markerGroup: d3.Selection<SVGGElement, ILinePoint[], null, undefined>;
    private axisGroup: d3.Selection<SVGGElement, ILinePoint[], null, undefined>;
    private xAxis: d3.Selection<SVGGElement, ILinePoint[], null, undefined>;
    private yAxis: d3.Selection<SVGGElement, ILinePoint[], null, undefined>;

    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.l
Download .txt
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
Download .txt
SYMBOL INDEX (206 symbols across 97 files)

FILE: api/_helpers/camel-case-keys.ts
  function camelCaseKeys (line 3) | function camelCaseKeys(data: Record<string, any>): Record<string, any> {

FILE: client/build/development.js
  constant CSS_LOADERS (line 6) | const CSS_LOADERS = [

FILE: client/build/production.js
  constant CSS_LOADERS (line 11) | const CSS_LOADERS = [

FILE: client/src/components/charts/_base/chart.ts
  function chart (line 16) | function chart(Chart) {

FILE: client/src/constants/core/migrations.ts
  type Migration (line 12) | type Migration = (settings: ISettings) => ISettings;

FILE: client/src/constants/forecast/figure.ts
  type IFigure (line 4) | interface IFigure extends Record<number, string> {}

FILE: client/src/constants/forecast/formats.ts
  function weatherTransform (line 25) | function weatherTransform(value: IForecastWeather[]): Record<string, any> {
  constant BASE_FORMATS (line 31) | const BASE_FORMATS = {

FILE: client/src/constants/forecast/formatters.ts
  function baseFormatter (line 12) | function baseFormatter<T>(raw: T, formatted: any) {
  function toSuffix (line 23) | function toSuffix(suffix: string, transformer: Function = functionIdenti...
  function defaultFormatter (line 27) | function defaultFormatter(value) {

FILE: client/src/constants/forecast/sections.ts
  type IForecastSection (line 14) | interface IForecastSection {

FILE: client/src/constants/forecast/tides-chart-options.ts
  type ChartOptions (line 19) | type ChartOptions = ILineOptions<Formatted<IForecastTideHeight>>;

FILE: client/src/constants/forecast/trends.ts
  type ChartOptions (line 24) | type ChartOptions = ILineOptions<Formatted<IForecastHour>>;
  type ITrend (line 26) | interface ITrend {
  constant BASE_OPTIONS (line 33) | const BASE_OPTIONS = {

FILE: client/src/constants/forecast/unit-of-measure.ts
  type UnitOfMeasure (line 5) | type UnitOfMeasure = Record<OBSERVATION, UNIT_OF_MEASURE>

FILE: client/src/constants/forecast/uv-index.ts
  type IUVIndex (line 1) | interface IUVIndex {

FILE: client/src/constants/maps/maps.ts
  type IMapLegend (line 9) | interface IMapLegend {
  type IMapLayer (line 14) | interface IMapLayer {
  type IMap (line 20) | interface IMap {
  function getOwmTileUrl (line 27) | function getOwmTileUrl(layer: string): string {
  function getRadarLayers (line 31) | function getRadarLayers(forecast: Formatted<IMappedForecast>, format: IF...

FILE: client/src/controllers/application.ts
  function visibilityChanged (line 19) | function visibilityChanged() {
  class ApplicationController (line 28) | class ApplicationController {
    method constructor (line 30) | constructor() {
    method setLocation (line 34) | async setLocation() {
    method setMapType (line 38) | async setMapType(): Promise<MAP> {
    method notify (line 42) | async notify(title: string, options?: NotificationOptions): Promise<No...

FILE: client/src/enums/core/status.ts
  type STATUS (line 1) | const enum STATUS {

FILE: client/src/enums/forecast/location.ts
  type LOCATION (line 1) | const enum LOCATION {

FILE: client/src/enums/forecast/observation.ts
  type OBSERVATION (line 1) | const enum OBSERVATION {

FILE: client/src/enums/forecast/phase.ts
  type PHASE (line 1) | const enum PHASE {

FILE: client/src/enums/forecast/section.ts
  type FORECAST_SECTION (line 1) | const enum FORECAST_SECTION {

FILE: client/src/enums/forecast/trend.ts
  type TREND (line 1) | const enum TREND {

FILE: client/src/enums/forecast/unit-of-measure.ts
  type UNIT_OF_MEASURE (line 1) | const enum UNIT_OF_MEASURE {

FILE: client/src/enums/forecast/units.ts
  type UNITS (line 1) | const enum UNITS {

FILE: client/src/enums/maps/map.ts
  type MAP (line 1) | const enum MAP {

FILE: client/src/helpers/get-direction.ts
  function getDirection (line 5) | function getDirection(bearing: number): string {

FILE: client/src/helpers/get-figure.ts
  function getFigure (line 7) | function getFigure(conditionId: number): string {

FILE: client/src/helpers/get-icon.ts
  function getIcon (line 6) | function getIcon(conditionId: number, timestamp?: number): string {

FILE: client/src/helpers/get-phase.ts
  function getPhase (line 7) | function getPhase(timestamp: number): PHASE {

FILE: client/src/helpers/set-theme-meta.ts
  function setThemeMeta (line 5) | function setThemeMeta(colour: string): void {

FILE: client/src/services/location.ts
  function searchLocations (line 5) | async function searchLocations(query: string): Promise<ILocation[]> {
  function getLocation (line 11) | async function getLocation(latitude: number, longitude: number): Promise...

FILE: client/src/services/weather.ts
  function getForecast (line 5) | async function getForecast(latitude: number, longitude: number, units?: ...

FILE: client/src/startup/application.ts
  function initialiseApplication (line 7) | function initialiseApplication() {

FILE: client/src/startup/components.ts
  function initialiseComponents (line 7) | function initialiseComponents(application: App) {

FILE: client/src/startup/index.ts
  function start (line 10) | async function start() {

FILE: client/src/startup/logging.ts
  function initialiseLogging (line 14) | function initialiseLogging() {

FILE: client/src/startup/router.ts
  type Window (line 20) | interface Window {
  function initialiseRouter (line 25) | function initialiseRouter(application: App) {

FILE: client/src/startup/state.ts
  function initialiseComponents (line 9) | function initialiseComponents(application: App) {

FILE: client/src/startup/worker.ts
  function initialiseWorker (line 22) | async function initialiseWorker() {

FILE: client/src/store/actions/add-location.ts
  function addLocation (line 16) | function addLocation(location: ILocation, setAsCurrent: boolean = false)...

FILE: client/src/store/actions/load-forecast.ts
  function loadForecast (line 10) | async function loadForecast(latitude: number, longitude: number) {

FILE: client/src/store/actions/load-location.ts
  function loadLocation (line 20) | async function loadLocation(): Promise<ILocation> {

FILE: client/src/store/actions/move-section.ts
  function moveSection (line 13) | function moveSection(type: FORECAST_SECTION, offset: number = 1): void {

FILE: client/src/store/actions/remove-location.ts
  function removeLocation (line 11) | function removeLocation(location: ILocation): void {

FILE: client/src/store/actions/reset-settings.ts
  function resetSettings (line 12) | function resetSettings() {

FILE: client/src/store/actions/set-current-location.ts
  function setCurrentLocation (line 5) | function setCurrentLocation(): void {

FILE: client/src/store/actions/set-location.ts
  function setLocation (line 18) | function setLocation(location: ILocation | LOCATION): void {

FILE: client/src/store/actions/set-section-visibility.ts
  function setSectionVisibility (line 9) | function setSectionVisibility(type: FORECAST_SECTION, isVisible = true):...

FILE: client/src/store/actions/update-settings.ts
  function updateSettings (line 7) | function updateSettings(settings: Partial<ISettings>): void {

FILE: client/src/store/actions/update.ts
  function update (line 19) | async function update(force: boolean = false) {

FILE: client/src/store/getters/theme.ts
  function getPhasedTheme (line 22) | function getPhasedTheme(theme: ITheme): ITheme {

FILE: client/src/store/helpers/location.ts
  function getPosition (line 5) | async function getPosition(): Promise<ICoordinate> {

FILE: client/src/store/helpers/storage.ts
  function getSettings (line 15) | function getSettings(): ISettings {
  function getData (line 44) | function getData(): IStoredData {
  function saveSettings (line 58) | function saveSettings(settings: ISettings): void {
  function saveData (line 62) | function saveData({ lastUpdated, location, forecast }: IStoredData): void {
  function clearData (line 70) | function clearData(): void {

FILE: client/src/store/mutations/set-last-updated.ts
  function setLastUpdated (line 5) | function setLastUpdated(value: Date | null = new Date()): void {

FILE: client/src/store/mutations/set-settings.ts
  function setSettings (line 14) | function setSettings(value: Partial<ISettings>) {

FILE: client/src/store/mutations/set-status.ts
  function setStatus (line 7) | function setStatus(status: STATUS = null): void {

FILE: client/src/store/state/index.ts
  function getState (line 10) | function getState(): IState {

FILE: client/src/types/location.ts
  type ICoordinate (line 1) | interface ICoordinate {
  type ILocation (line 6) | interface ILocation extends ICoordinate {

FILE: client/src/types/state.ts
  type Formatted (line 18) | type Formatted<T, U = string> =  {
  type IState (line 25) | interface IState {
  type IMappedForecastCurrent (line 33) | interface IMappedForecastCurrent extends Omit<IForecastCurrent, 'weather...
  type IMappedForecastDay (line 37) | interface IMappedForecastDay extends Omit<IForecastDay, 'weather'> {
  type IMappedForecastHour (line 41) | interface IMappedForecastHour extends Omit<IForecastHour, 'weather'> {
  type IMappedForecast (line 45) | interface IMappedForecast extends Omit<IForecast, 'current' | 'daily' | ...
  type IFormatter (line 52) | interface IFormatter {

FILE: client/src/types/storage.ts
  type ISection (line 14) | interface ISection {
  type IForecastSettings (line 20) | interface IForecastSettings {
  type IMapSettings (line 24) | interface IMapSettings {
  type ISettings (line 31) | interface ISettings {
  type IStoredData (line 41) | interface IStoredData {

FILE: client/src/types/themes.ts
  type ITheme (line 1) | interface ITheme {

FILE: client/src/types/weather.ts
  type IForecast (line 1) | interface IForecast {
  type IForecastWeather (line 13) | interface IForecastWeather {
  type IForecastFeelsLike (line 20) | interface IForecastFeelsLike {
  type IForecastTemperature (line 27) | interface IForecastTemperature {
  type IForecastCurrent (line 36) | interface IForecastCurrent {
  type IForecastHour (line 55) | interface IForecastHour {
  type IForecastDay (line 75) | interface IForecastDay {
  type IForecastTideHeight (line 94) | interface IForecastTideHeight {
  type IForecastTideExtreme (line 100) | interface IForecastTideExtreme extends IForecastTideHeight {
  type IForecastTides (line 104) | interface IForecastTides {
  type IForecastRadar (line 118) | interface IForecastRadar {

FILE: packages/charts/src/charts/_base/chart.ts
  type IChartOptions (line 8) | interface IChartOptions {
  method constructor (line 37) | constructor(element: Element) {
  method defaultOptions (line 54) | protected get defaultOptions(): IChartOptions {
  method bootstrap (line 72) | protected bootstrap(options: T) {
  method reset (line 97) | protected reset() {

FILE: packages/charts/src/charts/line/enums/line-type.ts
  type LINE_TYPE (line 1) | const enum LINE_TYPE {

FILE: packages/charts/src/charts/line/enums/marker-type.ts
  type MARKER_TYPE (line 1) | const enum MARKER_TYPE {

FILE: packages/charts/src/charts/line/index.ts
  class LineChart (line 35) | class LineChart extends Chart<ILineOptions> {
    method constructor (line 43) | constructor(element: Element) {
    method defaultOptions (line 54) | protected get defaultOptions(): ILineOptions {
    method bootstrap (line 104) | protected bootstrap(options: ILineOptions) {
    method reset (line 118) | protected reset() {
    method drawMarkers (line 123) | private async drawMarkers() {
    method drawArea (line 165) | private async drawArea() {
    method drawLine (line 193) | private async drawLine() {
    method drawAxes (line 222) | private drawAxes() {
    method draw (line 259) | private async draw() {
    method getAxis (line 267) | private getAxis(axisType, scale, options) {
    method calculate (line 292) | private calculate<T>(data: T[]) {
    method render (line 327) | public async render<T>(data: T[], options: ILineOptions) {

FILE: packages/charts/src/charts/line/types/index.ts
  type ILinePoint (line 10) | interface ILinePoint<T = any> {
  type ILineScaleOptions (line 19) | interface ILineScaleOptions<T> {
  type ILineOptions (line 25) | interface ILineOptions<T = any> extends IChartOptions {

FILE: packages/charts/src/enums/scale.ts
  type SCALE (line 1) | const enum SCALE {

FILE: packages/charts/src/scales/index.ts
  constant SCALES (line 7) | const SCALES = {
  function getScale (line 13) | function getScale(data, options, range) {

FILE: packages/components/src/components/mapbox/compositions/layer.ts
  function useLayer (line 50) | function useLayer(props: any, layer: Layer) {

FILE: packages/components/src/components/mapbox/types/index.ts
  type IInteractiveMap (line 7) | interface IInteractiveMap {

FILE: packages/components/src/compositions/layer.ts
  function open (line 26) | function open(payload: any, promisePayload: IPromisePayload) {
  function close (line 33) | function close(payload: any) {
  function cancel (line 44) | function cancel(payload: any) {

FILE: packages/components/src/compositions/timer.ts
  type Timer (line 6) | type Timer = 'interval' | 'timeout';
  type ITimerApplication (line 8) | interface ITimerApplication {
  constant TIMER (line 13) | const TIMER = {
  function useTimer (line 30) | function useTimer(handler: Function, timeout: number, timer: Timer = 'in...

FILE: packages/components/src/controllers/components.ts
  class ComponentsController (line 9) | class ComponentsController {
    method open (line 11) | public async open<T = any>(id: string, payload?: any): Promise<T> {
    method close (line 17) | public close(id: string, payload?: any): void {
    method confirm (line 21) | public async confirm(payload: IConfirmModalPayload): Promise<void> {

FILE: packages/components/src/directives/focus.ts
  method mounted (line 7) | mounted(el, binding) {

FILE: packages/components/src/directives/meta.ts
  method mounted (line 11) | mounted(element, { arg, value }) {
  method updated (line 15) | updated(element, { arg, value }) {
  method unmounted (line 19) | unmounted(element, { arg }) {

FILE: packages/components/src/directives/tooltip.ts
  function upsert (line 5) | function upsert(element: HTMLElement, binding) {
  method unmounted (line 20) | unmounted(element) {

FILE: packages/components/src/directives/visible.ts
  constant VISIBILITY_MAP (line 6) | const VISIBILITY_MAP = {
  function updateVisibility (line 11) | function updateVisibility(element: HTMLElement, binding: DirectiveBindin...
  method unmounted (line 20) | unmounted(element) {

FILE: packages/components/src/helpers/get-listeners.ts
  function getListeners (line 5) | function getListeners(attrs: Record<string, any>): Record<string, Functi...

FILE: packages/components/src/index.ts
  type Registrar (line 10) | type Registrar = 'directive' | 'component';
  function register (line 12) | function register(application: App, registrar: Registrar, dictionary: Re...
  method install (line 26) | install(application: App) {

FILE: packages/components/src/types/index.ts
  type IPromisePayload (line 1) | interface IPromisePayload {
  type IConfirmModalPayload (line 6) | interface IConfirmModalPayload {

FILE: packages/event-emitter/src/index.ts
  type IListener (line 1) | interface IListener {
  class EventEmitter (line 5) | class EventEmitter {
    method constructor (line 11) | constructor() {
    method on (line 15) | on(event: string, handler: Function): IListener {
    method off (line 27) | off(event: string, handler: Function): void {
    method once (line 41) | once(event: string, handler: Function): IListener {
    method emit (line 50) | emit(event: string, ...args) {

FILE: packages/router/src/index.ts
  method install (line 24) | install(app: App, routes: RouteRecordRaw[]) {

FILE: packages/state/src/index.ts
  type StoreAccessor (line 23) | type StoreAccessor = () => object;
  function logMutation (line 29) | function logMutation(name: string, state: any, isError: boolean = false)...
  function mapStore (line 37) | function mapStore(state: object): CustomInspectorNode[] {
  method install (line 55) | install(app) {
  function enableDevtools (line 90) | function enableDevtools(value: boolean = true): void {
  function createStore (line 94) | function createStore<T extends object = any>(name: string, data: T): ISt...

FILE: packages/state/src/types/index.ts
  type Getter (line 5) | type Getter<T, U> = (state: T) => U;
  type Mutation (line 6) | type Mutation<T> = (state: T) => void;
  type IStore (line 8) | interface IStore<T> {

FILE: packages/task-queue/src/index.ts
  class TaskQueue (line 1) | class TaskQueue {
    method constructor (line 6) | constructor(suppressErrors: boolean = false) {
    method suppressErrors (line 11) | get suppressErrors() {
    method suppressErrors (line 15) | set suppressErrors(value) {
    method add (line 19) | add(task: Function): void {
    method remove (line 23) | remove(task: Function): void {
    method clear (line 27) | clear(): void {
    method run (line 31) | run(...args: any[]): void {

FILE: packages/utilities/src/array/join-by.ts
  type Iteratee (line 3) | type Iteratee<T> = (value: T) => string;
  function joinBy (line 5) | function joinBy<T>(array: T[], iteratee: Iteratee<T> = value => String(v...

FILE: packages/utilities/src/array/swap-by.ts
  type Predicate (line 5) | type Predicate<T> = (value: T) => boolean;
  function getIndex (line 7) | function getIndex<T>(array: T[], predicate: number | Predicate<T>): numb...
  function swapBy (line 13) | function swapBy<T>(array: T[], predicateA: number | Predicate<T>, predic...

FILE: packages/utilities/src/dom/set-meta.ts
  function setMeta (line 1) | function setMeta(key: string, value: string = ''): void {

FILE: packages/utilities/src/env/_base/is-env.ts
  type Environment (line 1) | type Environment = 'development' | 'production';
  function isEnv (line 3) | function isEnv(environment: Environment): boolean {

FILE: packages/utilities/src/function/identity.ts
  function identity (line 1) | function identity(value) {

FILE: packages/utilities/src/object/clone-lazy.ts
  function cloneLazy (line 1) | function cloneLazy<T extends Object>(value: T): T {

FILE: packages/utilities/src/object/transform.ts
  type Transformer (line 7) | type Transformer = <T>(value: any, key?: PropertyKey, input?: T) => any;
  type SchemaValue (line 8) | type SchemaValue = any | any[] | Transformer | Object;
  type Schema (line 10) | interface Schema {
  function getTransformer (line 14) | function getTransformer(schemaValue: SchemaValue, baseTransformer: Trans...
  function transformArray (line 27) | function transformArray(input: any[], schemaValue: any[], baseTransforme...
  function transformObject (line 33) | function transformObject<T, U = any>(input: T, schema: Schema, baseTrans...
  function transform (line 49) | function transform<T, U = any>(input: T, schema: Schema, baseTransformer...

FILE: packages/utilities/src/scale/_base/scale.ts
  type Calculation (line 3) | type Calculation<T> = (value: T) => number;
  type IScale (line 5) | interface IScale<T = number> {
  function scale (line 11) | function scale<T = number>(domain: T[], range: number[], calculation: Ca...

FILE: packages/utilities/src/scale/continuous.ts
  function continuous (line 7) | function continuous(

FILE: packages/utilities/src/scale/discrete.ts
  function discrete (line 7) | function discrete<T>(

FILE: packages/utilities/src/string/unique-id.ts
  function uniqueId (line 3) | function uniqueId(length: number = 6): string {

FILE: packages/utilities/src/value/get-accessor.ts
  type Product (line 5) | type Product<T> = (...args: any[]) => T;
  function getAccessor (line 7) | function getAccessor<T>(identity: T | Product<T>): Product<T> {
Condensed preview — 290 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (285K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 676,
    "preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 197,
    "preview": "version: 2\nupdates:\n- package-ecosystem: npm\n  directory: \"/\"\n  schedule:\n    interval: monthly\n    day: wednesday\n    t"
  },
  {
    "path": ".gitignore",
    "chars": 143,
    "preview": "node_modules/\njspm_packages/\ntypings/\npublic/\n\n.cache\n.env\n.env.build\n\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-er"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 313,
    "preview": "{\n    \"files.exclude\": {\n        \"**/.git\": true,\n        \"**/.svn\": true,\n        \"**/.hg\": true,\n        \"**/CVS\": tru"
  },
  {
    "path": "BACKERS.md",
    "chars": 25,
    "preview": "# Backers\n\nKerry Tarrant\n"
  },
  {
    "path": "LICENSE",
    "chars": 1072,
    "preview": "MIT License\n\nCopyright (c) 2019 Andrew Courtice\n\nPermission is hereby granted, free of charge, to any person obtaining a"
  },
  {
    "path": "README.md",
    "chars": 4159,
    "preview": "<p align=\"center\">\n    <a href=\"https://app.ocula.io\">\n        <img src=\"https://github.com/andrewcourtice/ocula/raw/mas"
  },
  {
    "path": "_config.yml",
    "chars": 26,
    "preview": "theme: jekyll-theme-cayman"
  },
  {
    "path": "api/_helpers/camel-case-keys.ts",
    "chars": 582,
    "preview": "import toCamelCase from './to-camel-case';\n\nexport default function camelCaseKeys(data: Record<string, any>): Record<str"
  },
  {
    "path": "api/_helpers/to-camel-case.ts",
    "chars": 136,
    "preview": "export default function(value: string): string {\n    return value.toLowerCase().replace(/([-_]\\w)/g, group => group[1].t"
  },
  {
    "path": "api/location/_helpers/map-location.ts",
    "chars": 272,
    "preview": "export default function(feature) {\n    const {\n        id,\n        text,\n        place_name,\n        center\n    } = feat"
  },
  {
    "path": "api/location/coordinates.ts",
    "chars": 753,
    "preview": "import fetch from 'node-fetch';\nimport mapLocation from './_helpers/map-location';\n\nimport {\n    NowRequest,\n    NowResp"
  },
  {
    "path": "api/location/search.ts",
    "chars": 734,
    "preview": "import fetch from 'node-fetch';\nimport mapLocation from './_helpers/map-location';\n\nimport {\n    NowRequest,\n    NowResp"
  },
  {
    "path": "api/package.json",
    "chars": 297,
    "preview": "{\n    \"name\": \"@ocula/api\",\n    \"version\": \"1.0.0\",\n    \"repository\": \"https://github.com/andrewcourtice/ocula.git\",\n   "
  },
  {
    "path": "api/tsconfig.json",
    "chars": 98,
    "preview": "{\n    \"extends\": \"../tsconfig.json\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\"\n    }\n}"
  },
  {
    "path": "api/weather/forecast.ts",
    "chars": 1176,
    "preview": "import fetch from 'node-fetch';\n\nimport camelCaseKeys from '../_helpers/camel-case-keys';\n\nimport {\n    NowRequest,\n    "
  },
  {
    "path": "client/.browserslistrc",
    "chars": 76,
    "preview": "chrome >= 58\nfirefox >= 54\nedge >= 18\nsafari >= 11\nios_saf >= 11\nopera >= 55"
  },
  {
    "path": "client/babel.config.js",
    "chars": 297,
    "preview": "module.exports = {\n    presets: [\n        ['@babel/env', {\n            useBuiltIns: 'entry',\n            corejs: 3\n     "
  },
  {
    "path": "client/build/_base/config.js",
    "chars": 3273,
    "preview": "import path from 'path';\n\nimport HtmlWebpackPlugin from 'html-webpack-plugin';\nimport CopyWebpackPlugin from 'copy-webpa"
  },
  {
    "path": "client/build/_base/workbox.js",
    "chars": 560,
    "preview": "export default {\n    swDest: 'service-worker.js',\n    clientsClaim: true,\n    //skipWaiting: true,\n    navigateFallback:"
  },
  {
    "path": "client/build/development.js",
    "chars": 1606,
    "preview": "import MiniCssExtractPlugin from 'mini-css-extract-plugin';\n\nimport merge from 'webpack-merge';\nimport base from './_bas"
  },
  {
    "path": "client/build/insights.js",
    "chars": 348,
    "preview": "import merge from 'webpack-merge';\nimport production from './production';\n\nimport {\n    BundleAnalyzerPlugin\n} from 'web"
  },
  {
    "path": "client/build/production.js",
    "chars": 2303,
    "preview": "import TerserPlugin from 'terser-webpack-plugin';\nimport OptimiseCSSPlugin from 'optimize-css-assets-webpack-plugin';\nim"
  },
  {
    "path": "client/package.json",
    "chars": 2333,
    "preview": "{\n    \"name\": \"@ocula/client\",\n    \"title\": \"Ocula\",\n    \"version\": \"1.0.3\",\n    \"main\": \"./src/index.ts\",\n    \"reposito"
  },
  {
    "path": "client/postcss.config.js",
    "chars": 74,
    "preview": "module.exports = {\n    plugins: [\n        require('autoprefixer')\n    ]\n};"
  },
  {
    "path": "client/src/app.vue",
    "chars": 3070,
    "preview": "<template>\n    <layout class=\"app transition-theme-change\" :class=\"appClass\" footer>\n        <router-view />\n        <te"
  },
  {
    "path": "client/src/assets/images/figures/index.ts",
    "chars": 1146,
    "preview": "import fog from './fog.svg';\nimport fullMoon from './full-moon.svg';\nimport lightRain from './light-rain.svg';\nimport li"
  },
  {
    "path": "client/src/components/charts/_base/chart.ts",
    "chars": 1675,
    "preview": "import EVENTS from '../../../constants/core/events';\n\nimport {\n    defineComponent, \n    ref,\n    watch,\n    onMounted,\n"
  },
  {
    "path": "client/src/components/charts/line.vue",
    "chars": 317,
    "preview": "<template>\n    <div class=\"line-chart\" ref=\"element\"></div>\n</template>\n\n<script lang=\"ts\">\nimport {\n    LineChart\n} fro"
  },
  {
    "path": "client/src/components/charts/trends.vue",
    "chars": 3134,
    "preview": "<template>\n    <div class=\"trends-chart\">\n        <div class=\"trends-chart__body\" :style=\"bodyStyle\">\n            <line-"
  },
  {
    "path": "client/src/components/drawers/maps.vue",
    "chars": 1495,
    "preview": "<template>\n    <drawer :id=\"id\" class=\"maps-drawer\" position=\"top\" ref=\"drawer\">\n        <template #default=\"{ close }\">"
  },
  {
    "path": "client/src/components/forecast/daily-forecast.vue",
    "chars": 6938,
    "preview": "<template>\n    <accordion class=\"forecast-daily\">\n        <template #default=\"accordion\">\n            <table class=\"fore"
  },
  {
    "path": "client/src/components/forecast/hourly-forecast.vue",
    "chars": 3907,
    "preview": "<template>\n    <div class=\"forecast-hourly\">\n        <div class=\"forecast-hourly__header\" layout=\"row center-justify\">\n "
  },
  {
    "path": "client/src/components/forecast/summary.vue",
    "chars": 2213,
    "preview": "<template>\n    <div class=\"forecast-summary\">\n        <div layout=\"row center-justify\">\n            <div>\n              "
  },
  {
    "path": "client/src/components/forecast/tides.vue",
    "chars": 2164,
    "preview": "<template>\n    <div class=\"forecast-tides\">\n        <div class=\"forecast-tides__header\" :grid=\"tides.extremes.length\">\n "
  },
  {
    "path": "client/src/components/forecast/today.vue",
    "chars": 3568,
    "preview": "<template>\n    <transition-box-resize class=\"forecast-today\" grid=\"3\">\n        <div class=\"forecast-today__observation\" "
  },
  {
    "path": "client/src/components/forecast/uv-index.vue",
    "chars": 3464,
    "preview": "<template>\n    <div class=\"forecast-uv-index\">\n        <div class=\"forecast-uv-index__bar-wrapper\">\n            <div cla"
  },
  {
    "path": "client/src/components/layouts/settings.vue",
    "chars": 931,
    "preview": "<template>\n    <div class=\"settings-layout\">\n        <div class=\"settings-layout__header\" layout=\"row center-left\">\n    "
  },
  {
    "path": "client/src/components/layouts/weather.vue",
    "chars": 2133,
    "preview": "<template>\n    <div class=\"weather-layout\">\n        <slot v-if=\"hasLocationSet\"></slot>\n        <container v-else>\n     "
  },
  {
    "path": "client/src/components/modals/location.vue",
    "chars": 3819,
    "preview": "<template>\n    <modal :id=\"id\" class=\"location-modal\" ref=\"modal\" @open=\"reset\">\n        <search-box class=\"location-mod"
  },
  {
    "path": "client/src/components/settings/settings-item.vue",
    "chars": 877,
    "preview": "<template>\n    <div class=\"settings-item\" layout=\"row center-justify\">\n        <div class=\"settings-item__label\">\n      "
  },
  {
    "path": "client/src/components/weather/actions.vue",
    "chars": 1912,
    "preview": "<template>\n    <div class=\"weather-actions\" :class=\"actionsClass\" layout=\"row center-justify\">\n        <icon-button clas"
  },
  {
    "path": "client/src/components/weather/observation.vue",
    "chars": 931,
    "preview": "<template>\n    <div class=\"weather-observation\" layout=\"row center-left\">\n        <icon class=\"margin__right--small\" :na"
  },
  {
    "path": "client/src/constants/core/data.ts",
    "chars": 81,
    "preview": "export default {\n    lastUpdated: null,\n    location: null,\n    forecast: null\n};"
  },
  {
    "path": "client/src/constants/core/drawers.ts",
    "chars": 52,
    "preview": "export default {\n    maps: 'drawer:maps'\n} as const;"
  },
  {
    "path": "client/src/constants/core/events.ts",
    "chars": 285,
    "preview": "export default {\n    application: {\n        visible: 'application:visible',\n        resized: 'application:resized'\n    }"
  },
  {
    "path": "client/src/constants/core/global.ts",
    "chars": 48,
    "preview": "export default {\n    updateThreshold: 1800000\n};"
  },
  {
    "path": "client/src/constants/core/migrations.ts",
    "chars": 779,
    "preview": "import SETTINGS from '../core/settings';\n\nimport {\n    arrayUnionWith,\n    objectTransform\n} from '@ocula/utilities';\n\ni"
  },
  {
    "path": "client/src/constants/core/modals.ts",
    "chars": 61,
    "preview": "export default {\n    locations: 'modal:locations'\n} as const;"
  },
  {
    "path": "client/src/constants/core/routes.ts",
    "chars": 599,
    "preview": "export default {\n    forecast: {\n        index: 'forecast:index',\n    },\n    maps: {\n        index: 'maps:index'\n    },\n"
  },
  {
    "path": "client/src/constants/core/settings.ts",
    "chars": 1045,
    "preview": "import UNITS from '../../enums/forecast/units';\nimport MAP from '../../enums/maps/map';\nimport FORECAST_SECTION from '.."
  },
  {
    "path": "client/src/constants/core/storage-keys.ts",
    "chars": 74,
    "preview": "export default {\n    data: 'ocula:data',\n    settings: 'ocula:settings'\n};"
  },
  {
    "path": "client/src/constants/forecast/directions.ts",
    "chars": 182,
    "preview": "export default [\n    'N',\n    'NNE',\n    'NE',\n    'ENE',\n    'E',\n    'ESE',\n    'SE',\n    'SSE',\n    'S',\n    'SSW',\n "
  },
  {
    "path": "client/src/constants/forecast/figure.ts",
    "chars": 940,
    "preview": "import figures from '../../assets/images/figures';\nimport PHASE from '../../enums/forecast/phase';\n\ninterface IFigure ex"
  },
  {
    "path": "client/src/constants/forecast/formats.ts",
    "chars": 5414,
    "preview": "import UNITS from '../../enums/forecast/units';\n\nimport FORMATTERS, {\n    defaultFormatter\n} from './formatters';\n\nimpor"
  },
  {
    "path": "client/src/constants/forecast/formatters.ts",
    "chars": 2206,
    "preview": "import UNIT_OF_MEASURE from '../../enums/forecast/unit-of-measure';\n\nimport getIcon from '../../helpers/get-icon';\nimpor"
  },
  {
    "path": "client/src/constants/forecast/icon.ts",
    "chars": 1419,
    "preview": "import PHASE from '../../enums/forecast/phase';\n\nexport default {\n    [PHASE.day]: {\n        200: 'thunderstorms-line',\n"
  },
  {
    "path": "client/src/constants/forecast/sections.ts",
    "chars": 1266,
    "preview": "import FORECAST_SECTION from '../../enums/forecast/section';\n\nimport DailyForecast from '../../components/forecast/daily"
  },
  {
    "path": "client/src/constants/forecast/theme.ts",
    "chars": 339,
    "preview": "import {\n    weather\n} from '../../themes';\n\nexport default {\n    200: weather.rainy,\n    300: weather.rainy,\n    500: w"
  },
  {
    "path": "client/src/constants/forecast/tides-chart-options.ts",
    "chars": 818,
    "preview": "import {\n    ILineOptions,\n    LINE_TYPE\n} from '@ocula/charts';\n\nimport {\n    dateFromUnix,\n    numberRound\n} from '@oc"
  },
  {
    "path": "client/src/constants/forecast/trends.ts",
    "chars": 2533,
    "preview": "import TREND from '../../enums/forecast/trend';\nimport OBSERVATION from '../../enums/forecast/observation';\n\nimport {\n  "
  },
  {
    "path": "client/src/constants/forecast/unit-of-measure.ts",
    "chars": 846,
    "preview": "import UNITS from '../../enums/forecast/units';\nimport OBSERVATION from '../../enums/forecast/observation';\nimport UNIT_"
  },
  {
    "path": "client/src/constants/forecast/units.ts",
    "chars": 186,
    "preview": "import UNITS from '../../enums/forecast/units';\n\nexport default {\n    [UNITS.metric]: {\n        label: 'Metric'\n    },\n "
  },
  {
    "path": "client/src/constants/forecast/uv-index.ts",
    "chars": 653,
    "preview": "interface IUVIndex {\n    id: string;\n    label: string;\n    start: number;\n    colour: string;\n};\n\nexport default [\n    "
  },
  {
    "path": "client/src/constants/maps/maps.ts",
    "chars": 3444,
    "preview": "import MAP from '../../enums/maps/map';\n\nimport type {\n    Formatted,\n    IFormatter,\n    IMappedForecast\n} from '../../"
  },
  {
    "path": "client/src/controllers/application.ts",
    "chars": 1267,
    "preview": "import MAP from '../enums/maps/map';\n\nimport EVENTS from '../constants/core/events';\nimport MODALS from '../constants/co"
  },
  {
    "path": "client/src/enums/core/status.ts",
    "chars": 91,
    "preview": "const enum STATUS {\n    loading = 'loading',\n    error = 'error'\n};\n\nexport default STATUS;"
  },
  {
    "path": "client/src/enums/forecast/location.ts",
    "chars": 74,
    "preview": "const enum LOCATION {\n    current = 'current'\n};\n\nexport default LOCATION;"
  },
  {
    "path": "client/src/enums/forecast/observation.ts",
    "chars": 294,
    "preview": "const enum OBSERVATION {\n    temperature = 'temperature',\n    precipitation = 'precipitation',\n    humidity = 'humidity'"
  },
  {
    "path": "client/src/enums/forecast/phase.ts",
    "chars": 81,
    "preview": "const enum PHASE {\n    day = 'day',\n    night = 'night'\n};\n\nexport default PHASE;"
  },
  {
    "path": "client/src/enums/forecast/section.ts",
    "chars": 211,
    "preview": "const enum FORECAST_SECTION {\n    today = 'today',\n    dailyForecast = 'daily-forecast',\n    hourlyForecast = 'hourly-fo"
  },
  {
    "path": "client/src/enums/forecast/trend.ts",
    "chars": 133,
    "preview": "const enum TREND {\n    temperature = 'temperature',\n    rainfall = 'rainfallprobability',\n    wind = 'wind'\n};\n\nexport d"
  },
  {
    "path": "client/src/enums/forecast/unit-of-measure.ts",
    "chars": 547,
    "preview": "const enum UNIT_OF_MEASURE {\n    // Distance\n    millimeters = 'mm',\n    centimetres = 'cm',\n    metres = 'm',\n    kilom"
  },
  {
    "path": "client/src/enums/forecast/units.ts",
    "chars": 93,
    "preview": "const enum UNITS {\n    metric = 'metric',\n    imperial = 'imperial'\n};\n\nexport default UNITS;"
  },
  {
    "path": "client/src/enums/maps/map.ts",
    "chars": 205,
    "preview": "export const enum MAP {\n    radar = 'radar',\n    precipitation = 'precipitation',\n    temperature = 'temperature',\n    c"
  },
  {
    "path": "client/src/helpers/get-direction.ts",
    "chars": 234,
    "preview": "import DIRECTIONS from '../constants/forecast/directions';\n\nconst divisor = 360 / DIRECTIONS.length;\n\nexport default fun"
  },
  {
    "path": "client/src/helpers/get-figure.ts",
    "chars": 328,
    "preview": "import FIGURE from '../constants/forecast/figure';\n\nimport {\n    phase\n} from '../store';\n\nexport default function getFi"
  },
  {
    "path": "client/src/helpers/get-icon.ts",
    "chars": 452,
    "preview": "import ICON from '../constants/forecast/icon';\nimport PHASE from '../enums/forecast/phase';\n\nimport getPhase from './get"
  },
  {
    "path": "client/src/helpers/get-phase.ts",
    "chars": 389,
    "preview": "import PHASE from '../enums/forecast/phase';\n\nimport {\n    state\n} from '../store';\n\nexport default function getPhase(ti"
  },
  {
    "path": "client/src/helpers/set-theme-meta.ts",
    "chars": 153,
    "preview": "import {\n    domSetMeta\n} from '@ocula/utilities';\n\nexport default function setThemeMeta(colour: string): void {\n    dom"
  },
  {
    "path": "client/src/index.ejs",
    "chars": 5060,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <title>Ocula</title>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-"
  },
  {
    "path": "client/src/index.ts",
    "chars": 55,
    "preview": "import start from './startup';\n\nexport default start();"
  },
  {
    "path": "client/src/routes/error/index.ts",
    "chars": 418,
    "preview": "import ROUTES from '../../constants/core/routes';\n\nimport Index from './index.vue';\nimport NotFound from './not-found.vu"
  },
  {
    "path": "client/src/routes/error/index.vue",
    "chars": 189,
    "preview": "<template>\n    <div class=\"route error-index\">\n        <div class=\"text--centre\">\n            <h1>Error</h1>\n           "
  },
  {
    "path": "client/src/routes/error/not-found.vue",
    "chars": 179,
    "preview": "<template>\n    <div class=\"route error-not-found\">\n        <div class=\"text--centre\">\n            <h1>404</h1>\n         "
  },
  {
    "path": "client/src/routes/error.vue",
    "chars": 94,
    "preview": "<template>\n    <div class=\"route error-master\">\n        <router-view />\n    </div>\n</template>"
  },
  {
    "path": "client/src/routes/forecast/index.ts",
    "chars": 368,
    "preview": "import ROUTES from '../../constants/core/routes';\n\nimport type {\n    RouteRecordRaw\n} from '@ocula/router';\n\nimport {\n  "
  },
  {
    "path": "client/src/routes/forecast/index.vue",
    "chars": 3521,
    "preview": "<template>\n    <div class=\"route forecast-index transition-theme-change\" layout=\"column top-stretch\" :class=\"theme.weath"
  },
  {
    "path": "client/src/routes/forecast.vue",
    "chars": 346,
    "preview": "<template>\n    <weather-layout class=\"route forecast-master\">\n        <router-view />\n    </weather-layout>    \n</templa"
  },
  {
    "path": "client/src/routes/index.ts",
    "chars": 831,
    "preview": "import Forecast from './forecast.vue';\nimport Maps from './maps.vue';\nimport Settings from './settings.vue';\nimport Erro"
  },
  {
    "path": "client/src/routes/maps/index.ts",
    "chars": 387,
    "preview": "import ROUTES from '../../constants/core/routes';\n\nimport {\n    defineAsyncComponent\n} from 'vue';\n\nimport type {\n    Ro"
  },
  {
    "path": "client/src/routes/maps/index.vue",
    "chars": 9174,
    "preview": "<template>\n    <div class=\"route maps-index\" layout=\"column top-stretch\">\n        <div>\n            <container>\n        "
  },
  {
    "path": "client/src/routes/maps.vue",
    "chars": 342,
    "preview": "<template>\n    <weather-layout class=\"route maps-master\">\n        <router-view />\n    </weather-layout>    \n</template>\n"
  },
  {
    "path": "client/src/routes/settings/forecast/index.ts",
    "chars": 484,
    "preview": "import ROUTES from '../../../constants/core/routes';\n\nimport Locations from './locations.vue';\nimport Sections from './s"
  },
  {
    "path": "client/src/routes/settings/forecast/locations.vue",
    "chars": 1391,
    "preview": "<template>\n    <settings-layout class=\"route settings-locations\" title=\"Locations\" :back-route=\"backRoute\">\n        <div"
  },
  {
    "path": "client/src/routes/settings/forecast/sections.vue",
    "chars": 2552,
    "preview": "<template>\n    <settings-layout class=\"route settings-sections\" title=\"Sections\" :back-route=\"backRoute\">\n        <trans"
  },
  {
    "path": "client/src/routes/settings/general/about.vue",
    "chars": 3713,
    "preview": "<template>\n    <settings-layout class=\"route settings-about\" title=\"About\" :back-route=\"backRoute\">\n        <div class=\""
  },
  {
    "path": "client/src/routes/settings/general/index.ts",
    "chars": 445,
    "preview": "import ROUTES from '../../../constants/core/routes';\n\nimport Theme from './theme.vue';\nimport About from './about.vue';\n"
  },
  {
    "path": "client/src/routes/settings/general/theme.vue",
    "chars": 2610,
    "preview": "<template>\n    <settings-layout class=\"route settings-themes\" title=\"Themes\" :back-route=\"backRoute\">\n        <div class"
  },
  {
    "path": "client/src/routes/settings/index.ts",
    "chars": 422,
    "preview": "import ROUTES from '../../constants/core/routes';\n\nimport Index from './index.vue';\n\nimport forecast from './forecast';\n"
  },
  {
    "path": "client/src/routes/settings/index.vue",
    "chars": 7319,
    "preview": "<template>\n    <settings-layout class=\"route settings-index\">\n        <div class=\"settings-index__body\">\n            <bl"
  },
  {
    "path": "client/src/routes/settings/maps/display.vue",
    "chars": 3000,
    "preview": "<template>\n    <settings-layout class=\"route settings-maps-display\" title=\"Display\" :back-route=\"backRoute\">\n        <di"
  },
  {
    "path": "client/src/routes/settings/maps/index.ts",
    "chars": 302,
    "preview": "import ROUTES from '../../../constants/core/routes';\n\nimport Display from './display.vue';\n\nimport type {\n    RouteRecor"
  },
  {
    "path": "client/src/routes/settings.vue",
    "chars": 320,
    "preview": "<template>\n    <div class=\"route settings-master\">\n        <container class=\"settings-master__container\">\n            <r"
  },
  {
    "path": "client/src/services/location.ts",
    "chars": 466,
    "preview": "import {\n    ILocation\n} from '../types/location';\n\nexport async function searchLocations(query: string): Promise<ILocat"
  },
  {
    "path": "client/src/services/weather.ts",
    "chars": 314,
    "preview": "import type {\n    IForecast\n} from '../types/weather';\n\nexport async function getForecast(latitude: number, longitude: n"
  },
  {
    "path": "client/src/startup/application.ts",
    "chars": 147,
    "preview": "import {\n    createApp\n} from 'vue';\n\nimport App from '../app.vue';\n\nexport default function initialiseApplication() {\n "
  },
  {
    "path": "client/src/startup/components.ts",
    "chars": 188,
    "preview": "import Components from '@ocula/components';\n\nimport type {\n    App\n} from 'vue';\n\nexport default function initialiseComp"
  },
  {
    "path": "client/src/startup/index.ts",
    "chars": 676,
    "preview": "import './vendor';\n\nimport initialiseComponents from './components';\nimport initialiseRouter from './router';\nimport ini"
  },
  {
    "path": "client/src/startup/logging.ts",
    "chars": 462,
    "preview": "import {\n    envIsProduction\n} from '@ocula/utilities';\n\nimport {\n    init\n} from '@sentry/browser';\n\n\n// import {\n//   "
  },
  {
    "path": "client/src/startup/router.ts",
    "chars": 1078,
    "preview": "import ROUTES from '../constants/core/routes';\n\nimport routes from '../routes';\n\nimport setThemeMeta from '../helpers/se"
  },
  {
    "path": "client/src/startup/state.ts",
    "chars": 183,
    "preview": "import {\n    plugin\n} from '@ocula/state';\n\nimport type {\n    App\n} from 'vue';\n\nexport default function initialiseCompo"
  },
  {
    "path": "client/src/startup/vendor.ts",
    "chars": 62,
    "preview": "import 'core-js/stable';\nimport 'regenerator-runtime/runtime';"
  },
  {
    "path": "client/src/startup/worker.ts",
    "chars": 1485,
    "preview": "import {\n    Workbox,\n    messageSW\n} from 'workbox-window';\n\nimport {\n    clearData\n} from '../store/helpers/storage';\n"
  },
  {
    "path": "client/src/static/.well-known/somthing.txt",
    "chars": 7,
    "preview": "sdfgdsf"
  },
  {
    "path": "client/src/static/robots.txt",
    "chars": 41,
    "preview": "Sitemap: https://app.ocula.io/sitemap.xml"
  },
  {
    "path": "client/src/static/sitemap.xml",
    "chars": 303,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"> \n    <url>\n        <"
  },
  {
    "path": "client/src/store/actions/add-location.ts",
    "chars": 568,
    "preview": "import updateSettings from './update-settings';\nimport setLocation from './set-location';\n\nimport type {\n    ILocation\n}"
  },
  {
    "path": "client/src/store/actions/load-forecast.ts",
    "chars": 377,
    "preview": "import {\n    state,\n    mutate\n} from '../store';\n\nimport {\n    getForecast\n} from '../../services/weather';\n\nexport def"
  },
  {
    "path": "client/src/store/actions/load-location.ts",
    "chars": 826,
    "preview": "import LOCATION from '../../enums/forecast/location';\n\nimport {\n    state,\n    mutate\n} from '../store';\n\nimport {\n    g"
  },
  {
    "path": "client/src/store/actions/move-section.ts",
    "chars": 568,
    "preview": "import FORECAST_SECTION from '../../enums/forecast/section';\n\nimport updateSettings from './update-settings';\n\nimport {\n"
  },
  {
    "path": "client/src/store/actions/remove-location.ts",
    "chars": 369,
    "preview": "import updateSettings from './update-settings';\n\nimport type {\n    ILocation\n} from '../../types/location';\n\nimport {\n  "
  },
  {
    "path": "client/src/store/actions/reset-settings.ts",
    "chars": 320,
    "preview": "import SETTINGS from '../../constants/core/settings';\n\nimport {\n    mutate\n} from '../store';\n\nimport {\n    saveSettings"
  },
  {
    "path": "client/src/store/actions/search-locations.ts",
    "chars": 230,
    "preview": "import {\n    searchLocations\n} from '../../services/location';\n\nimport type {\n    ILocation\n} from '../../types/location"
  },
  {
    "path": "client/src/store/actions/set-current-location.ts",
    "chars": 187,
    "preview": "import LOCATION from '../../enums/forecast/location';\n\nimport setLocation from './set-location';\n\nexport default functio"
  },
  {
    "path": "client/src/store/actions/set-location.ts",
    "chars": 639,
    "preview": "import LOCATION from '../../enums/forecast/location';\nimport EVENTS from '../../constants/core/events';\n\nimport setLastU"
  },
  {
    "path": "client/src/store/actions/set-section-visibility.ts",
    "chars": 587,
    "preview": "import FORECAST_SECTION from '../../enums/forecast/section';\n\nimport updateSettings from './update-settings';\n\nimport {\n"
  },
  {
    "path": "client/src/store/actions/update-settings.ts",
    "chars": 218,
    "preview": "import setSettings from '../mutations/set-settings';\n\nimport type {\n    ISettings\n} from '../../types/storage';\n\nexport "
  },
  {
    "path": "client/src/store/actions/update.ts",
    "chars": 1142,
    "preview": "import STATUS from '../../enums/core/status';\n\nimport GLOBAL from '../../constants/core/global';\n\nimport setStatus from "
  },
  {
    "path": "client/src/store/getters/forecast.ts",
    "chars": 907,
    "preview": "import UNITS from '../../enums/forecast/units';\nimport FORMATS from '../../constants/forecast/formats';\n\nimport {\n    de"
  },
  {
    "path": "client/src/store/getters/format.ts",
    "chars": 756,
    "preview": "import {\n    getter\n} from '../store';\n\nimport {\n    dateFormat,\n    dateUtcToZoned\n} from '@ocula/utilities';\n\nimport t"
  },
  {
    "path": "client/src/store/getters/phase.ts",
    "chars": 203,
    "preview": "import getPhase from '../../helpers/get-phase';\n\nimport {\n    getter\n} from '../store';\n\nimport {\n    dateToUnix\n} from "
  },
  {
    "path": "client/src/store/getters/theme.ts",
    "chars": 1247,
    "preview": "import THEME from '../../constants/forecast/theme';\n\nimport phase from './phase';\n\nimport {\n    getter\n} from '../store'"
  },
  {
    "path": "client/src/store/getters/unit-of-measure.ts",
    "chars": 249,
    "preview": "import UNITS from '../../enums/forecast/units';\nimport UNIT_OF_MEASURE from '../../constants/forecast/unit-of-measure';\n"
  },
  {
    "path": "client/src/store/helpers/location.ts",
    "chars": 470,
    "preview": "import type {\n    ICoordinate\n} from '../../types/location';\n\nexport async function getPosition(): Promise<ICoordinate> "
  },
  {
    "path": "client/src/store/helpers/storage.ts",
    "chars": 1874,
    "preview": "import DATA from '../../constants/core/data';\nimport SETTINGS from '../../constants/core/settings';\nimport MIGRATIONS fr"
  },
  {
    "path": "client/src/store/index.ts",
    "chars": 1151,
    "preview": "export { state } from './store';\n\nexport { default as forecast } from './getters/forecast';\nexport { default as phase } "
  },
  {
    "path": "client/src/store/mutations/set-last-updated.ts",
    "chars": 189,
    "preview": "import {\n    mutate\n} from '../store';\n\nexport default function setLastUpdated(value: Date | null = new Date()): void {\n"
  },
  {
    "path": "client/src/store/mutations/set-settings.ts",
    "chars": 399,
    "preview": "import {\n    state,\n    mutate\n} from '../store';\n\nimport {\n    saveSettings\n} from '../helpers/storage';\n\nimport type {"
  },
  {
    "path": "client/src/store/mutations/set-status.ts",
    "chars": 211,
    "preview": "import STATUS from '../../enums/core/status';\n\nimport {\n    mutate\n} from '../store';\n\nexport default function setStatus"
  },
  {
    "path": "client/src/store/state/index.ts",
    "chars": 417,
    "preview": "import {\n    getSettings,\n    getData\n} from '../helpers/storage';\n\nimport {\n    IState\n} from '../../types/state';\n\nfun"
  },
  {
    "path": "client/src/store/store.ts",
    "chars": 154,
    "preview": "import createStore from '@ocula/state';\n\nimport _state from './state';\n\nexport const {\n    state,\n    getter,\n    mutate"
  },
  {
    "path": "client/src/themes/core/dark/_dark.scss",
    "chars": 152,
    "preview": "@mixin dark {\n    --font__colour: #F7F7F7;\n    --background__colour: #333333;\n    --background__colour--hover: #444444;\n"
  },
  {
    "path": "client/src/themes/core/dark/index.scss",
    "chars": 55,
    "preview": "@import \"./_dark\";\n\n.theme--dark {\n    @include dark;\n}"
  },
  {
    "path": "client/src/themes/core/dark/index.ts",
    "chars": 215,
    "preview": "import './index.scss';\n\nimport type {\n    ITheme\n} from '../../../types/themes';\n\nexport default {\n    id: 'dark',\n    n"
  },
  {
    "path": "client/src/themes/core/default/index.scss",
    "chars": 258,
    "preview": "@import \"../light/_light\";\n@import \"../dark/_dark\";\n\n.theme--default {\n    @include light;\n}\n\n.phase--night {\n\n    &.the"
  },
  {
    "path": "client/src/themes/core/default/index.ts",
    "chars": 358,
    "preview": "import './index.scss';\n\nimport PHASE from '../../../enums/forecast/phase';\n\nimport type {\n    ITheme\n} from '../../../ty"
  },
  {
    "path": "client/src/themes/core/index.ts",
    "chars": 153,
    "preview": "import _default from './default';\nimport light from './light';\nimport dark from './dark';\n\nexport default {\n    default:"
  },
  {
    "path": "client/src/themes/core/light/_light.scss",
    "chars": 153,
    "preview": "@mixin light {\n    --font__colour: #353539;\n    --background__colour: #FFFFFF;\n    --background__colour--hover: #EEEEEE;"
  },
  {
    "path": "client/src/themes/core/light/index.scss",
    "chars": 58,
    "preview": "@import \"./_light\";\n\n.theme--light {\n    @include light;\n}"
  },
  {
    "path": "client/src/themes/core/light/index.ts",
    "chars": 219,
    "preview": "import './index.scss';\n\nimport type {\n    ITheme\n} from '../../../types/themes';\n\nexport default {\n    id: 'light',\n    "
  },
  {
    "path": "client/src/themes/index.ts",
    "chars": 89,
    "preview": "export { default as core } from './core';\nexport { default as weather } from './weather';"
  },
  {
    "path": "client/src/themes/weather/clear/index.scss",
    "chars": 215,
    "preview": ".theme--weather-clear {\n    --font__colour--weather: #FFFFFF;\n    --background__colour--weather: #5D9BE5;\n}\n\n.phase--nig"
  },
  {
    "path": "client/src/themes/weather/clear/index.ts",
    "chars": 410,
    "preview": "import './index.scss';\n\nimport PHASE from '../../../enums/forecast/phase';\n\nimport type {\n    ITheme\n} from '../../../ty"
  },
  {
    "path": "client/src/themes/weather/default/index.ts",
    "chars": 165,
    "preview": "import type {\n    ITheme\n} from '../../../types/themes';\n\nexport default {\n    id: 'weather-default',\n    name: 'Default"
  },
  {
    "path": "client/src/themes/weather/index.ts",
    "chars": 218,
    "preview": "import _default from './default';\nimport clear from './clear';\nimport partlyCloudy from './partly-cloudy';\nimport rainy "
  },
  {
    "path": "client/src/themes/weather/partly-cloudy/index.scss",
    "chars": 231,
    "preview": ".theme--weather-partly-cloudy {\n    --font__colour--weather: #FFFFFF;\n    --background__colour--weather: #5D9BE5;\n}\n\n.ph"
  },
  {
    "path": "client/src/themes/weather/partly-cloudy/index.ts",
    "chars": 434,
    "preview": "import './index.scss';\n\nimport PHASE from '../../../enums/forecast/phase';\n\nimport type {\n    ITheme\n} from '../../../ty"
  },
  {
    "path": "client/src/themes/weather/rainy/index.scss",
    "chars": 107,
    "preview": ".theme--weather-rainy {\n    --font__colour--weather: #FFFFFF;\n    --background__colour--weather: #AAAAAA;\n}"
  },
  {
    "path": "client/src/themes/weather/rainy/index.ts",
    "chars": 212,
    "preview": "import './index.scss';\n\nimport type {\n    ITheme\n} from '../../../types/themes';\n\nexport default {\n    id: 'weather-rain"
  },
  {
    "path": "client/src/types/location.ts",
    "chars": 190,
    "preview": "export interface ICoordinate {\n    latitude: number;\n    longitude: number;\n}\n\nexport interface ILocation extends ICoord"
  },
  {
    "path": "client/src/types/state.ts",
    "chars": 1281,
    "preview": "import type {\n    ISettings\n} from './storage';\n\nimport type {\n    ILocation\n} from './location';\n\nimport type {\n    IFo"
  },
  {
    "path": "client/src/types/storage.ts",
    "chars": 896,
    "preview": "import type LOCATION from '../enums/forecast/location';\nimport UNITS from '../enums/forecast/units';\nimport type MAP fro"
  },
  {
    "path": "client/src/types/themes.ts",
    "chars": 183,
    "preview": "export interface ITheme {\n    id: string;\n    name: string;\n    colour: string | Record<string, string>;\n    class: stri"
  },
  {
    "path": "client/src/types/weather.ts",
    "chars": 2498,
    "preview": "export interface IForecast {\n    lat: number;\n    lon: number;\n    timezone: string;\n    timezoneOffset: number;\n    cur"
  },
  {
    "path": "client/src/vue-shim.d.ts",
    "chars": 75,
    "preview": "declare module \"*.vue\" {\n    import Vue from 'vue'\n    export default Vue\n}"
  },
  {
    "path": "client/tsconfig.json",
    "chars": 103,
    "preview": "{\n    \"extends\": \"../tsconfig.json\",\n    \"compilerOptions\": {\n        \"resolveJsonModule\": true\n    }\n}"
  },
  {
    "path": "client/webpack.config.babel.js",
    "chars": 50,
    "preview": "module.exports = env => require(`./build/${env}`);"
  },
  {
    "path": "package.json",
    "chars": 507,
    "preview": "{\n    \"name\": \"@ocula/app\",\n    \"repository\": \"https://github.com/andrewcourtice/ocula.git\",\n    \"author\": \"Andrew Court"
  },
  {
    "path": "packages/charts/package.json",
    "chars": 419,
    "preview": "{\n    \"name\": \"@ocula/charts\",\n    \"version\": \"1.0.0\",\n    \"main\": \"./src/index.ts\",\n    \"license\": \"MIT\",\n    \"dependen"
  },
  {
    "path": "packages/charts/src/charts/_base/chart.ts",
    "chars": 2387,
    "preview": "import * as d3 from '../../d3';\n\nimport {\n    objectMerge,\n    stringUniqueId\n} from '@ocula/utilities';\n\nexport interfa"
  },
  {
    "path": "packages/charts/src/charts/line/constants/curve.ts",
    "chars": 226,
    "preview": "import LINE_TYPE from '../enums/line-type';\n\nimport * as d3 from '../../../d3';\n\nexport default {\n    [LINE_TYPE.line]: "
  },
  {
    "path": "packages/charts/src/charts/line/enums/line-type.ts",
    "chars": 112,
    "preview": "const enum LINE_TYPE {\n    line = 'line',\n    spline = 'spline',\n    step = 'step'\n};\n\nexport default LINE_TYPE;"
  },
  {
    "path": "packages/charts/src/charts/line/enums/marker-type.ts",
    "chars": 97,
    "preview": "const enum MARKER_TYPE {\n    point = 'point',\n    arrow = 'arrow'\n};\n\nexport default MARKER_TYPE;"
  },
  {
    "path": "packages/charts/src/charts/line/index.ts",
    "chars": 9242,
    "preview": "import LINE_TYPE from './enums/line-type';\nimport MARKER_TYPE from './enums/marker-type';\n\nimport SCALE from '../../enum"
  },
  {
    "path": "packages/charts/src/charts/line/types/index.ts",
    "chars": 1180,
    "preview": "import LINE_TYPE from '../enums/line-type';\nimport MARKER_TYPE from '../enums/marker-type';\n\nimport SCALE from '../../.."
  },
  {
    "path": "packages/charts/src/d3/index.ts",
    "chars": 213,
    "preview": "export * from 'd3-array';\nexport * from 'd3-axis';\nexport * from 'd3-ease';\nexport * from 'd3-path';\nexport * from 'd3-s"
  },
  {
    "path": "packages/charts/src/enums/scale.ts",
    "chars": 106,
    "preview": "const enum SCALE {\n    linear = 'linear',\n    point = 'point',\n    time = 'time'\n};\n\nexport default SCALE;"
  },
  {
    "path": "packages/charts/src/index.ts",
    "chars": 290,
    "preview": "export { default as SCALE_TYPE } from './enums/scale';\n\nexport { default as LINE_TYPE } from './charts/line/enums/line-t"
  },
  {
    "path": "packages/charts/src/scales/index.ts",
    "chars": 420,
    "preview": "import SCALE from '../enums/scale';\n\nimport linear from './linear';\nimport point from './point';\nimport time from './tim"
  },
  {
    "path": "packages/charts/src/scales/linear.ts",
    "chars": 331,
    "preview": "import * as d3 from '../d3';\n\nexport default function(data, options, range) {\n    const {\n        value\n    } = options;"
  },
  {
    "path": "packages/charts/src/scales/point.ts",
    "chars": 234,
    "preview": "import * as d3 from '../d3';\n\nexport default function(data, options, range) {\n    const {\n        value\n    } = options;"
  },
  {
    "path": "packages/charts/src/scales/time.ts",
    "chars": 240,
    "preview": "import * as d3 from '../d3';\n\nexport default function(data, options, range) {\n    const {\n        value\n    } = options;"
  },
  {
    "path": "packages/components/package.json",
    "chars": 446,
    "preview": "{\n    \"name\": \"@ocula/components\",\n    \"version\": \"1.0.0\",\n    \"main\": \"./src/index.ts\",\n    \"license\": \"MIT\",\n    \"depe"
  },
  {
    "path": "packages/components/src/components/accordion/accordion-pane.vue",
    "chars": 2072,
    "preview": "<template>\n    <transition-box-resize class=\"accordion-pane\">\n        <div class=\"accordion-pane__header\" v-if=\"$slots.h"
  },
  {
    "path": "packages/components/src/components/accordion/accordion.vue",
    "chars": 1329,
    "preview": "<template>\n    <div class=\"accordion\">\n        <slot :open=\"open\" :close=\"close\" :toggle=\"toggle\"></slot>\n    </div>\n</t"
  },
  {
    "path": "packages/components/src/components/accordion/constants/events.ts",
    "chars": 252,
    "preview": "export default {\n    openPane: 'open-pane',\n    closePane: 'close-pane',\n    togglePane: 'toggle-pane',\n    paneOpened: "
  },
  {
    "path": "packages/components/src/components/block/block.vue",
    "chars": 956,
    "preview": "<template>\n    <section class=\"block\">\n        <div class=\"block__header\" layout=\"row center-justify\" v-if=\"$slots.title"
  },
  {
    "path": "packages/components/src/components/container/container.vue",
    "chars": 210,
    "preview": "<template>\n    <div class=\"container\">\n        <slot></slot>\n    </div>\n</template>\n\n<style lang=\"scss\">\n\n    .container"
  },
  {
    "path": "packages/components/src/components/core/confirm-modal.vue",
    "chars": 1381,
    "preview": "<template>\n    <modal :id=\"id\" class=\"confirm-modal\" @open=\"onOpen\">\n        <template #default=\"{ close, cancel }\">\n   "
  },
  {
    "path": "packages/components/src/components/core/index.vue",
    "chars": 255,
    "preview": "<template>\n    <confirm-modal />\n</template>\n\n<script lang=\"ts\">\nimport ConfirmModal from './confirm-modal.vue';\n\nimport"
  },
  {
    "path": "packages/components/src/components/drawer/drawer.vue",
    "chars": 2510,
    "preview": "<template>\n    <transition name=\"drawer\">\n        <div class=\"drawer\" v-if=\"isOpen\" @click.self=\"close()\">\n            <"
  },
  {
    "path": "packages/components/src/components/icon/icon.vue",
    "chars": 752,
    "preview": "<template>\n    <svg class=\"icon\" :class=\"className\">\n        <use v-bind:xlink:href=\"href\"/>\n    </svg>\n</template>\n\n<sc"
  },
  {
    "path": "packages/components/src/components/icon-button/icon-button.vue",
    "chars": 1578,
    "preview": "<template>\n    <div class=\"icon-button\" :class=\"buttonClass\" layout=\"row center-left\" v-bind=\"$attrs\">\n        <icon cla"
  },
  {
    "path": "packages/components/src/components/icon-label/icon-label.vue",
    "chars": 701,
    "preview": "<template>\n    <div class=\"icon-label\" layout=\"row center-left\">\n        <icon class=\"icon-label__icon\" :name=\"icon\"></i"
  },
  {
    "path": "packages/components/src/components/index.ts",
    "chars": 1129,
    "preview": "import Accordion from './accordion/accordion.vue';\nimport AccordionPane from './accordion/accordion-pane.vue';\nimport Bl"
  },
  {
    "path": "packages/components/src/components/layout/layout.vue",
    "chars": 2041,
    "preview": "<template>\n    <div name=\"layout\" class=\"layout\">\n        <header class=\"layout__header\" v-if=\"$slots.header && header\">"
  },
  {
    "path": "packages/components/src/components/loader/loader.vue",
    "chars": 1481,
    "preview": "<template>\n    <div class=\"loader\" :class=\"className\"></div>\n</template>\n\n<script lang=\"ts\">\nimport {\n    defineComponen"
  },
  {
    "path": "packages/components/src/components/mapbox/compositions/layer.ts",
    "chars": 1037,
    "preview": "import {\n    inject,\n    watch,\n    onMounted,\n    onBeforeUnmount,\n    PropType\n} from 'vue';\n\nimport {\n    stringUniqu"
  },
  {
    "path": "packages/components/src/components/mapbox/mapbox-legend.vue",
    "chars": 2011,
    "preview": "<template>\n    <div class=\"mapbox-legend\" :class=\"legendClass\">\n        <div class=\"mapbox-legend__header\" layout=\"row c"
  }
]

// ... and 90 more files (download for full content)

About this extraction

This page contains the full source code of the andrewcourtice/ocula GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 290 files (247.0 KB), approximately 67.0k tokens, and a symbol index with 206 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!