New and improved in sveltekit
10
.prettierrc
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||
"svelteStrictMode": true
|
||||
}
|
||||
BIN
elasticQueries/-X
Normal file
57
elasticQueries/temp_15min_30s.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"aggs": {
|
||||
"2": {
|
||||
"date_histogram": {
|
||||
"field": "@timestamp",
|
||||
"fixed_interval": "30s",
|
||||
"time_zone": "Europe/Oslo",
|
||||
"min_doc_count": 1
|
||||
},
|
||||
"aggs": {
|
||||
"1": {
|
||||
"max": {
|
||||
"field": "temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": 0,
|
||||
"fields": [
|
||||
{
|
||||
"field": "@timestamp",
|
||||
"format": "date_time"
|
||||
}
|
||||
],
|
||||
"script_fields": {},
|
||||
"stored_fields": ["*"],
|
||||
"_source": {
|
||||
"excludes": []
|
||||
},
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [],
|
||||
"filter": [
|
||||
{
|
||||
"match_all": {}
|
||||
},
|
||||
{
|
||||
"match_phrase": {
|
||||
"location.keyword": "inside"
|
||||
}
|
||||
},
|
||||
{
|
||||
"range": {
|
||||
"@timestamp": {
|
||||
"gte": "2022-04-03T13:04:26.460Z",
|
||||
"lte": "2022-04-03T13:19:26.460Z",
|
||||
"format": "strict_date_optional_time"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"should": [],
|
||||
"must_not": []
|
||||
}
|
||||
}
|
||||
}
|
||||
60
package.json
@@ -1,31 +1,41 @@
|
||||
{
|
||||
"name": "brewpi",
|
||||
"name": "schleppe-brew",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "svelte-kit dev",
|
||||
"build": "svelte-kit build",
|
||||
"package": "svelte-kit package",
|
||||
"preview": "svelte-kit preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
|
||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check src && eslint src",
|
||||
"format": "prettier --plugin-search-dir . --write src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
||||
"@typescript-eslint/parser": "^4.31.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-svelte3": "^3.2.1",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier-plugin-svelte": "^2.4.0",
|
||||
"svelte": "^3.44.0",
|
||||
"svelte-check": "^2.2.6",
|
||||
"svelte-preprocess": "^4.9.4",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.4.3"
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/adapter-static": "^1.0.0",
|
||||
"@sveltejs/kit": "^1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.1",
|
||||
"@typescript-eslint/parser": "^5.46.1",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-plugin-svelte": "^2.9.0",
|
||||
"sass": "^1.56.2",
|
||||
"svelte": "^3.55.0",
|
||||
"svelte-check": "^2.10.2",
|
||||
"svelte-preprocess": "^5.0.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"chart.js": "^3.8.0",
|
||||
"chartjs-plugin-zoom": "^1.2.1",
|
||||
"d3": "^7.8.4",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-timeseries": "^1.0.1",
|
||||
"d3-transition": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/app.html
@@ -3,11 +3,17 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="" />
|
||||
<link rel="icon" href="%svelte.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" type="text/css" href="/global.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/variables.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%svelte.head%
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div id="svelte">%svelte.body%</div>
|
||||
|
||||
<body data-sveltekit-prefetch>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
91
src/brews.json
Normal file
@@ -0,0 +1,91 @@
|
||||
[{
|
||||
"beer": {
|
||||
"name": "Kveldsbris",
|
||||
"brewery": "Kinn Bryggeri",
|
||||
"category": "Pilsner/Lys Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1682272800",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "5.6",
|
||||
"description": "",
|
||||
"image": "kinn_kveldsbris.png",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/104923/finest-originals-utepils-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "FUCK YEAH IPA",
|
||||
"brewery": "Finest",
|
||||
"category": "American IPA",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1648922400",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "7",
|
||||
"description": "",
|
||||
"image": "finest_fuck-yeah-IPA.jpg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://web.archive.org/web/20210225043236/https://www.bryggselv.no/finest/105943/fuck-yeah-ipa-ultra-american-west-coast-ipa-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Love in a canoe",
|
||||
"brewery": "Finest",
|
||||
"category": "Mexican Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1646420400",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "4.7",
|
||||
"description": "",
|
||||
"image": "finest_love-in-a-canoe.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/104092/love-in-a-canoe-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Utepils",
|
||||
"brewery": "Finest",
|
||||
"category": "",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1637694000",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "5.0",
|
||||
"description": "",
|
||||
"image": "finest_utepils.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "HELLES Tysk Lager",
|
||||
"brewery": "Münchener Helles",
|
||||
"category": "Tysk Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1629396000",
|
||||
"by": ["Adrian", "Kevin", "Mats"],
|
||||
"abv": "5.3",
|
||||
"description": "",
|
||||
"image": "helles_tysk-lager.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Lazy Days Weiss",
|
||||
"brewery": "Finest",
|
||||
"category": "Weissbier",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1621706400",
|
||||
"by": ["Alf", "Kevin", "Kristian"],
|
||||
"abv": "5.3",
|
||||
"description": "",
|
||||
"image": "finest_lazy-days.jpeg",
|
||||
"recipe": "https://docs.google.com/document/u/0/d/1I6qX4l4jDzK51GxBt3IdEv-HyNQHAx8ijc5dMlG1Xkk",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}]
|
||||
7
src/global.d.ts
vendored
@@ -1 +1,6 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
string;
|
||||
}
|
||||
|
||||
25
src/lib/components/CardButton.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="button-circle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.button-circle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
background-color: var(--backdrop);
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transition: scale(1.15);
|
||||
scale: 1.15;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/lib/components/Darkmode.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
// import { setTheme, theme } from '../themeStore'
|
||||
import type { Theme } from '../types'
|
||||
|
||||
function toggleDarkmode() {
|
||||
// setTheme(nextTheme);
|
||||
|
||||
// document.body.className = $theme
|
||||
}
|
||||
|
||||
function systemDarkModeEnabled() {
|
||||
const computedStyle = window.getComputedStyle(document.body);
|
||||
if (computedStyle['colorScheme'] != null) {
|
||||
return computedStyle.colorScheme.includes('dark');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$: icon = true ? '🌝' : '🌚';
|
||||
// $: icon = $theme === 'dark' ? '🌝' : '🌚';
|
||||
// $: nextTheme = ($theme === 'dark' ? 'light' : 'dark') as Theme
|
||||
|
||||
// onMount(() => document.body.className = $theme)
|
||||
</script>
|
||||
|
||||
<div class="darkToggle">
|
||||
<span on:click={() => toggleDarkmode()}>{icon}</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.darkToggle {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
cursor: pointer;
|
||||
// background-color: red;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 2rem;
|
||||
z-index: 10;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
199
src/lib/components/Graph.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
|
||||
import type { ChartDataset } from 'chart.js';
|
||||
import type IChartFrame from '../interfaces/IChartFrame';
|
||||
|
||||
Chart.register(
|
||||
LineElement,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Legend
|
||||
);
|
||||
|
||||
export let name: string;
|
||||
export let dataFrames: IChartFrame[];
|
||||
export let beginAtZero: boolean = true;
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
let prevData: any = {};
|
||||
|
||||
interface IDataset {
|
||||
labels: string[];
|
||||
data?: ChartDataset<'line', number[]>;
|
||||
}
|
||||
|
||||
interface ITemperatureDataset extends IDataset {
|
||||
inside: ChartDataset<'line', number[]>;
|
||||
outside?: ChartDataset<'line', number[]>;
|
||||
}
|
||||
|
||||
interface IHumidityDataset extends IDataset {}
|
||||
interface IPressureDataset extends IDataset {}
|
||||
|
||||
function pad(num) {
|
||||
if (num < 10) {
|
||||
return `0${num}`;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function prettierDateString(date) {
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${pad(date.getYear() - 100)}`;
|
||||
}
|
||||
|
||||
function computeTemperatureDataset(): ITemperatureDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
inside: {
|
||||
label: '℃ inside',
|
||||
borderColor: '#10e783',
|
||||
backgroundColor: '#c8f9df',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function computeHumidityDataset(): IHumidityDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
data: {
|
||||
label: '% humidity',
|
||||
borderColor: '#57d2fb',
|
||||
backgroundColor: '#d4f2fe',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function computePressureDataset(): IPressureDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
data: {
|
||||
label: 'Bar of pressure',
|
||||
borderColor: '#ef5878',
|
||||
backgroundColor: '#fbd7de',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const context: CanvasRenderingContext2D = chartCanvas.getContext('2d');
|
||||
|
||||
let dataset: IDataset | ITemperatureDataset | IHumidityDataset | IPressureDataset;
|
||||
if (name === 'Temperature') dataset = computeTemperatureDataset();
|
||||
else if (name === 'Humidity') dataset = computeHumidityDataset();
|
||||
else if (name === 'Pressure') dataset = computePressureDataset();
|
||||
|
||||
chart = new Chart(context, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dataset.labels,
|
||||
datasets: [dataset?.inside || dataset.data]
|
||||
},
|
||||
options: {
|
||||
elements: {
|
||||
point: {
|
||||
radius: 1
|
||||
}
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
position: 'left',
|
||||
text: `${name} over time`,
|
||||
font: {
|
||||
size: 20
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
usePointStyle: true,
|
||||
borderRadius: 10,
|
||||
labels: {
|
||||
padding: 12,
|
||||
boxWidth: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
speed: 0.001
|
||||
},
|
||||
// pinch: {
|
||||
// enabled: true
|
||||
// },
|
||||
mode: 'xy'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
offset: true,
|
||||
ticks: {
|
||||
color: 'black'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.06)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.06)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chart.update();
|
||||
}
|
||||
|
||||
onMount(() => renderChart());
|
||||
afterUpdate(() => {
|
||||
console.log('after update run');
|
||||
chart.destroy();
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas id="{name}" bind:this="{chartCanvas}" width="400" height="400"></canvas>
|
||||
193
src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import Navigation from './Navigation.svelte';
|
||||
import GithubIcon from '../icons/Github.svelte';
|
||||
|
||||
let open: boolean = false;
|
||||
|
||||
function toggleMenu() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
$: headerText = !open ? 'Menu' : 'Close';
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="slideout-menu" transition:fly="{{ x: 550, duration: 300 }}">
|
||||
<h1>Navigation</h1>
|
||||
|
||||
<Navigation on:click={close} />
|
||||
|
||||
<ul class="bottom-content">
|
||||
<li>
|
||||
<a href="https://github.com/kevinmidboe/brewpi">
|
||||
<GithubIcon />
|
||||
<span class="meta">View on Github</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<header>
|
||||
<div on:click={toggleMenu} class:open aria-label="Open menu" class="menu">
|
||||
{#if !open}
|
||||
<span class="page-header-buttons__open">
|
||||
<span /> <span /> <span />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="page-header-buttons__close">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="page-header-text">{headerText}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.slideout-menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 550px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
background-color: #fff3f6;
|
||||
color: black;
|
||||
padding: calc(100px + 2rem) 2rem 1rem;
|
||||
border-top-left-radius: 4rem;
|
||||
|
||||
h1 {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
padding: 100px 2rem 2rem;
|
||||
}
|
||||
|
||||
.bottom-content {
|
||||
margin-top: auto;
|
||||
|
||||
li, li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.meta {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
padding: 0 1rem;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
background-color: var(--green);
|
||||
color: #fff3f6;
|
||||
padding: 14px 20px;
|
||||
border-radius: 25px;
|
||||
-webkit-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
background-color: salmon;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-text {
|
||||
padding-left: 11px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.page-header-buttons__open {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
|
||||
&:first-child {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
top: 11px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-buttons__close {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
|
||||
&:first-child {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
52
src/lib/components/Livestream.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<div class="livestream-container">
|
||||
<img src="https://i.imgur.com/T4fCMI5.png" alt="livestream" />
|
||||
|
||||
<div class="pulse"></div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.livestream-container {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
border: 0.5rem solid var(--background);
|
||||
transition: border-color var(--color-transition-duration) ease-in-out;
|
||||
display: inline-block;
|
||||
height: fit-content;
|
||||
|
||||
img {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
position: absolute;
|
||||
top: 1.2rem;
|
||||
left: 1.2rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 82, 82, 1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 1);
|
||||
height: 1.7rem;
|
||||
width: 1.7rem;
|
||||
transform: scale(1);
|
||||
animation: pulse-red 2s infinite;
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0.7);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 12px rgba(255, 82, 82, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/lib/components/Navigation.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import ArrowRight from "../icons/ArrowRight.svelte";
|
||||
|
||||
interface IRoute {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const routes: Array<IRoute> = [{
|
||||
name: 'Home',
|
||||
path: '/'
|
||||
}, {
|
||||
name: 'Past brews',
|
||||
path: '/brews'
|
||||
}, {
|
||||
name: 'Graphs',
|
||||
path: '/graphs'
|
||||
}]
|
||||
</script>
|
||||
|
||||
<ul class="navigation-cards" on:click>
|
||||
{#each routes as route}
|
||||
<a href={route.path}>
|
||||
<li>
|
||||
<span>{ route.name }</span>
|
||||
<ArrowRight />
|
||||
</li>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.navigation-cards a {
|
||||
display: block;
|
||||
border-radius: 2rem;
|
||||
background: var(--green);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
margin: 1rem 0;
|
||||
&:first-of-type {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.75rem;
|
||||
color: white;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/lib/components/NavigationFrontMenu.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Navigation from './Navigation.svelte'
|
||||
</script>
|
||||
|
||||
<div class="front-menu">
|
||||
<Navigation />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.front-menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
27
src/lib/components/PageHeader.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="page-header">
|
||||
<h1>Schleppe Brew</h1>
|
||||
<span class="subtitle">Monitor beer brewing refridgerator</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.page-header {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: 'Overpass';
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.2rem;
|
||||
|
||||
@include tablet {
|
||||
font-size: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
90
src/lib/components/RelayControls.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
// import { toggleRelay } from '$lib/server/relayToggle'
|
||||
import Switch from './Switch.svelte';
|
||||
|
||||
export let relays = [];
|
||||
|
||||
function toggleRelay(location) {
|
||||
const url = `/api/relay/${location}`;
|
||||
const options = {
|
||||
method: 'POST'
|
||||
};
|
||||
|
||||
fetch(url, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(console.log);
|
||||
}
|
||||
|
||||
function handleChange(event) {
|
||||
let isChecked = event.detail.checked;
|
||||
// Perform any desired actions based on the new value
|
||||
console.log('New value:', isChecked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relays-container">
|
||||
<h1>Manual relay controls</h1>
|
||||
|
||||
<div class="vertical-sensor-display">
|
||||
{#each relays as relay}
|
||||
<div>
|
||||
<h2>{relay.location} relay</h2>
|
||||
|
||||
<div class="sensor-reading">
|
||||
<Switch checked="{relay.state}" on:change="{() => toggleRelay(relay.location)}" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.relays-container {
|
||||
height: fit-content;
|
||||
border-radius: 12px;
|
||||
background-color: var(--background);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out;
|
||||
padding: 2.25rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
padding: 2.25rem 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-sensor-display {
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
text-transform: capitalize;
|
||||
|
||||
@include tablet {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-reading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
177
src/lib/components/Switch.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let checked = false;
|
||||
|
||||
function handleChange() {
|
||||
dispatch('change', { checked });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="switch-wrapper">
|
||||
<div class="switch-button-container">
|
||||
<input class="switch-checkbox" type="checkbox" bind:checked="{checked}" on:change="{handleChange}" >
|
||||
<div class="switch-button">
|
||||
<div class="switch-button-top">
|
||||
<svg class="switch-icon on" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44">
|
||||
<path d="M19 3h6v38h-6z"/>
|
||||
</svg>
|
||||
<svg class="switch-icon off" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44 22C44 34.1503 34.1503 44 22 44C9.84974 44 0 34.1503 0 22C0 9.84974 9.84974 0 22 0C34.1503 0 44 9.84974 44 22ZM5.5 22C5.5 31.1127 12.8873 38.5 22 38.5C31.1127 38.5 38.5 31.1127 38.5 22C38.5 12.8873 31.1127 5.5 22 5.5C12.8873 5.5 5.5 12.8873 5.5 22Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.switch-wrapper {
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
width: 100px;
|
||||
height: 125px;
|
||||
background-image: linear-gradient(to bottom, #414049, #30282c);
|
||||
box-shadow:
|
||||
0 0 1px #050506,
|
||||
inset 0 0 0 2px #050506,
|
||||
inset 0 3px 1px #66646c;
|
||||
}
|
||||
|
||||
.switch-button-container {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
padding: 3px 2px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
box-shadow: 0 0 1px #000;
|
||||
}
|
||||
|
||||
.switch-checkbox {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-button {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
border-radius: 5px 5px 2px 2px / 16px 16px 2px 2px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #7f070d;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(#770505, .6) 40%, rgba(#ff8d93, .6) 60% 75%, rgba(#fff, .9) 90%),
|
||||
linear-gradient(to bottom, rgba(#710206, .6), rgba(#d12127, .6))
|
||||
;
|
||||
background-size: 100% calc(15% + 1px), 100% 0%;
|
||||
background-position: top, bottom;
|
||||
background-repeat: no-repeat;
|
||||
box-shadow:
|
||||
inset 0 -3px 2px rgba(#000, .4),
|
||||
inset 0 3px 1px rgba(#000, .8),
|
||||
inset 1px 0 0 rgba(#691016, .8),
|
||||
inset -1px 0 0 rgba(#691016, .8),
|
||||
inset 1px 0 0 rgba(#d6585f, .8),
|
||||
inset -1px 0 0 rgba(#d6585f, .8);
|
||||
transition: border-radius .4s, background-size .4s;
|
||||
overflow: hidden;
|
||||
|
||||
.switch-checkbox:checked + & {
|
||||
border-radius: 2px 2px 5px 5px / 2px 2px 16px 16px;
|
||||
background-size: 100% 0, 100% 15%;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-button-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 15%;
|
||||
width: 100%;
|
||||
height: 85%;
|
||||
background-color: rgba(#770505, .8);
|
||||
background-image: radial-gradient(rgba(#900006, .8) 1px, transparent 0);
|
||||
background-size: 3px 3px;
|
||||
background-position: 50%;
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(#691016, .8),
|
||||
inset -1px 0 0 rgba(#691016, .8),
|
||||
inset 1px 0 0 rgba(#d6585f, .8),
|
||||
inset -1px 0 0 rgba(#d6585f, .8),
|
||||
inset 0 -1px 0 rgba(#fff, .2),
|
||||
inset 0 1px 0 rgba(#fff, .9);
|
||||
transition: all .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
top: 0;
|
||||
background-color: rgba(#e30320, .2);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-image: radial-gradient(closest-side at 50% 50%, #ffaf0f 0% 10%, #fc071e 30%, #4b0100);
|
||||
opacity: 0;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
opacity: 1;
|
||||
animation: flick .2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-color: rgba(#fdd8d8, .14);
|
||||
filter: blur(4px);
|
||||
transition: transform .4s, background-color .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
background-color: rgba(#fdd8d8, .2);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: #aa9094;
|
||||
transition: fill .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > .switch-button-top > & {
|
||||
fill: #f3d5df;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flick {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: .8;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
135
src/lib/components/VerticalSensorDisplay.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import CardButton from "./CardButton.svelte";
|
||||
import Activity from "../icons/Activity.svelte";
|
||||
|
||||
export let inside;
|
||||
export let outside;
|
||||
|
||||
let loadedTime: number = new Date().getTime();
|
||||
let currentTime: number = new Date().getTime()
|
||||
|
||||
function updateTime() {
|
||||
currentTime = new Date().getTime();
|
||||
}
|
||||
|
||||
function flipCard(): void {
|
||||
console.log("flip-a-delphia")
|
||||
}
|
||||
|
||||
onMount(() => setInterval(updateTime, 1000));
|
||||
|
||||
$: secondsSinceUpdate = Math.floor((currentTime - loadedTime) / 1000)
|
||||
</script>
|
||||
|
||||
|
||||
<div class="vertical-sensor-display">
|
||||
<CardButton>
|
||||
<Activity on:click={flipCard} />
|
||||
</CardButton>
|
||||
|
||||
<h2>Current target temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="red">
|
||||
<span class="value">16</span>
|
||||
<span class="unit">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Inside temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="blue">
|
||||
<span class="value">{ inside?.temperature }</span>
|
||||
<span class="unit">{ inside?.temperature_unit }</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="value">{ Math.floor(inside?.humidity) }</span>
|
||||
<span class="unit">{ inside?.humidity_unit || '%' }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Outside temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="blue">
|
||||
<span class="value">{ outside?.temperature }</span>
|
||||
<span class="unit">{ outside?.temperature_unit }</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="value">{ Math.floor(outside?.humidity) }</span>
|
||||
<span class="unit">{ outside?.humidity_unit }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Pressure</h2>
|
||||
<div class="sensor-reading">
|
||||
<span class="value">{ inside?.pressure || 0}</span>
|
||||
<span class="unit">bar</span>
|
||||
</div>
|
||||
|
||||
<div class="button-timer">
|
||||
<span>Updated { secondsSinceUpdate === 0 ? 'now' : secondsSinceUpdate + 's ago' }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.vertical-sensor-display {
|
||||
position: relative;
|
||||
padding: 2.25rem 1rem;
|
||||
border-radius: 12px;
|
||||
background-color: var(--background);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out;
|
||||
|
||||
@include tablet {
|
||||
padding: 2.25rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
|
||||
@include tablet {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-reading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
|
||||
@include tablet {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
.button-timer {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
color: rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
</style>
|
||||
20
src/lib/components/display.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export const title: string = "Temperature"
|
||||
|
||||
let temp = 23
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<header>{ title }</header>
|
||||
|
||||
<div class="body">
|
||||
<p>Inside temperature: {23}℃</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
270
src/lib/graphQueryGenerator.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type IESTelemetry from './interfaces/IESTelemetry';
|
||||
import type IChartFrame from './interfaces/IChartFrame';
|
||||
|
||||
const TELEMETRY_ENDPOINT = 'REPLACE_WITH_ES_HOST/brewlogger-*/_search';
|
||||
const ES_APIKEY = '***REMOVED***';
|
||||
|
||||
function dateToESString(date: Date) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
const buildLatestQuery = (place: string) => {
|
||||
return {
|
||||
sort: [{ '@timestamp': { order: 'desc', mode: 'max' } }],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
match_all: {}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'location.keyword': place
|
||||
}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'message.keyword': 'Sensor readings'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
size: 1
|
||||
};
|
||||
};
|
||||
|
||||
function buildQuery(field: String, from: Date, to: Date, interval: String) {
|
||||
const fromDateString: string = dateToESString(from);
|
||||
const toDateString: string = dateToESString(to);
|
||||
|
||||
return {
|
||||
aggs: {
|
||||
data: {
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: interval,
|
||||
time_zone: 'Europe/Oslo',
|
||||
min_doc_count: 1
|
||||
},
|
||||
aggs: {
|
||||
maxValue: {
|
||||
max: {
|
||||
field: field
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
size: 0,
|
||||
fields: [
|
||||
{
|
||||
field: '@timestamp',
|
||||
format: 'date_time'
|
||||
}
|
||||
],
|
||||
script_fields: {},
|
||||
stored_fields: ['*'],
|
||||
_source: {
|
||||
excludes: []
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
match_all: {}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'location.keyword': 'inside'
|
||||
}
|
||||
},
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: toDateString,
|
||||
lte: fromDateString,
|
||||
format: 'strict_date_optional_time'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
should: [],
|
||||
must_not: []
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function roundInterval(interval) {
|
||||
switch (true) {
|
||||
case interval <= 500:
|
||||
return '100ms';
|
||||
case interval <= 5e3:
|
||||
return '1s';
|
||||
case interval <= 7500:
|
||||
return '5s';
|
||||
case interval <= 15e3:
|
||||
return '10s';
|
||||
case interval <= 45e3:
|
||||
return '30s';
|
||||
case interval <= 18e4:
|
||||
return '1m';
|
||||
case interval <= 45e4:
|
||||
return '5m';
|
||||
case interval <= 12e5:
|
||||
return '10m';
|
||||
case interval <= 27e5:
|
||||
return '30m';
|
||||
case interval <= 72e5:
|
||||
return '1h';
|
||||
case interval <= 216e5:
|
||||
return '3h';
|
||||
case interval <= 864e5:
|
||||
return '12h';
|
||||
case interval <= 6048e5:
|
||||
return '24h';
|
||||
case interval <= 18144e5:
|
||||
return '3d';
|
||||
case interval < 36288e5:
|
||||
return '30d';
|
||||
default:
|
||||
return '1y';
|
||||
}
|
||||
}
|
||||
|
||||
function calculateInterval(from, to, interval, size) {
|
||||
if (interval !== 'auto') {
|
||||
return interval;
|
||||
}
|
||||
const dateMathInterval = roundInterval((from - to) / size);
|
||||
// const dateMathIntervalMs = toMS(dateMathInterval);
|
||||
// const minMs = toMS(min);
|
||||
// if (dateMathIntervalMs !== undefined && minMs !== undefined && dateMathIntervalMs < minMs) {
|
||||
// return min;
|
||||
// }
|
||||
return dateMathInterval;
|
||||
}
|
||||
|
||||
function parseTempResponse(data: IESTelemetry): IChartFrame[] {
|
||||
return data?.aggregations?.data?.buckets.map((bucket) => {
|
||||
return {
|
||||
value: bucket?.maxValue?.value,
|
||||
key_as_string: bucket?.key_as_string,
|
||||
key: bucket?.key
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseLatestResponse(data: IESTelemetry) {
|
||||
return data?.hits?.hits[0]?._source;
|
||||
}
|
||||
|
||||
export function fetchTemperature(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'temperature';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function fetchHumidity(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'humidity';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function fetchPressure(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'pressure';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function getLatestInsideReadings(fetch: Function) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(buildLatestQuery('inside'))
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseLatestResponse);
|
||||
}
|
||||
|
||||
export function getLatestOutsideReadings(fetch: Function) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(buildLatestQuery('outside'))
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseLatestResponse);
|
||||
}
|
||||
1
src/lib/icons/Activity.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
1
src/lib/icons/AlignLeft.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
1
src/lib/icons/ArrowRight.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 323 B |
1
src/lib/icons/Github.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
|
After Width: | Height: | Size: 814 B |
1
src/lib/icons/TrendingUp.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trending-up"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 338 B |
5
src/lib/interfaces/IChartFrame.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface IChartFrame {
|
||||
value: number;
|
||||
key: number;
|
||||
key_as_string: string;
|
||||
}
|
||||
52
src/lib/interfaces/IESTelemetry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type ESRelation = 'eq' | 'lt' | 'gt';
|
||||
|
||||
interface IESHit {
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: IESSource;
|
||||
}
|
||||
|
||||
interface IESSource {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
location: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
'@timestamp': string;
|
||||
sessionID: string;
|
||||
}
|
||||
|
||||
export default interface ESTelemetry {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
_shards: {
|
||||
total: number;
|
||||
successful: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
relation: ESRelation;
|
||||
};
|
||||
max_score: null;
|
||||
hits: Array<IESHit>;
|
||||
};
|
||||
aggregations: {
|
||||
data: {
|
||||
buckets: [
|
||||
{
|
||||
maxValue: {
|
||||
value: number;
|
||||
};
|
||||
key_as_string: string;
|
||||
key: number;
|
||||
doc_count: number;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
17
src/lib/themeStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable, get, derived } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
// import { session } from '$app/stores';
|
||||
import type { Theme } from './types';
|
||||
|
||||
export const theme = derived<Writable<App.Session>, Theme>(session, ($session, set) => {
|
||||
if ($session.theme) {
|
||||
set($session.theme);
|
||||
} else if (browser) {
|
||||
set(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
export const setTheme = (theme: Theme) => {
|
||||
session.update(($session) => ({ ...$session, theme }));
|
||||
fetch('/theme', { method: 'PUT', body: theme });
|
||||
};
|
||||
4
src/lib/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const themes = ['light', 'dark'] as const;
|
||||
|
||||
export type Theme = typeof themes[number];
|
||||
export const isTheme = (theme: string): theme is Theme => themes.includes(theme as Theme);
|
||||
29
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import HeaderComponent from '$lib/components/Header.svelte';
|
||||
// import Darkmode from '$lib/components/Darkmode.svelte'
|
||||
</script>
|
||||
|
||||
<HeaderComponent />
|
||||
<!-- <Darkmode/> -->
|
||||
|
||||
<div class="page-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2.5em;
|
||||
margin-top: var(--header-height);
|
||||
|
||||
@include mobile {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getLatestInsideReadings, getLatestOutsideReadings } from '$lib/graphQueryGenerator';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
let DEFAULT_MINUTES = 14400;
|
||||
const host = 'http://brewpi.schleppe:5000';
|
||||
const sensorsUrl = `${host}/api/sensors`;
|
||||
const relaysUrl = `${host}/api/relays`;
|
||||
|
||||
async function getSensors() {
|
||||
return fetch(sensorsUrl)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => {
|
||||
return response?.sensors;
|
||||
});
|
||||
}
|
||||
|
||||
async function getRelays() {
|
||||
return fetch(relaysUrl)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => {
|
||||
return response?.relays || [];
|
||||
});
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [sensors, relays] = await Promise.all([getSensors(), getRelays()]);
|
||||
console.log('got sensors and relays');
|
||||
console.log(sensors, relays);
|
||||
|
||||
const inside = sensors.find((sensor) => sensor.location === 'inside');
|
||||
const outside = sensors.find((sensor) => sensor.location === 'outside');
|
||||
|
||||
return {
|
||||
inside: inside || null,
|
||||
outside: outside || null,
|
||||
relays
|
||||
};
|
||||
};
|
||||
41
src/routes/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Display from '$lib/components/display.svelte'
|
||||
import VerticalSensorDisplay from '$lib/components/VerticalSensorDisplay.svelte'
|
||||
import Livestream from '$lib/components/Livestream.svelte'
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import RelayControls from '../lib/components/RelayControls.svelte';
|
||||
|
||||
export let data: PageData
|
||||
const { inside, outside, relays } = data;
|
||||
</script>
|
||||
|
||||
<PageHeader />
|
||||
|
||||
<div class="vertical-grid">
|
||||
<Navigation />
|
||||
|
||||
<VerticalSensorDisplay {inside} {outside} />
|
||||
|
||||
<RelayControls {relays} />
|
||||
|
||||
<!-- <Livestream /> -->
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
.vertical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 2rem;
|
||||
row-gap: 15px;
|
||||
margin: 1rem;
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 1fr 2fr 3fr;
|
||||
margin: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/routes/api/relay/[location]/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// const BREWPI_URL = ''
|
||||
const BREWPI_URL = 'http://brewpi.schleppe:5000';
|
||||
|
||||
export const POST = (async ({ request }) => {
|
||||
const { pathname } = new URL(request.url);
|
||||
|
||||
const options = { method: 'POST' };
|
||||
return fetch(BREWPI_URL + pathname, options)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => json(response));
|
||||
}) satisfies RequestHandler;
|
||||
6
src/routes/brews/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import brews from '../../brews.json'
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { brews };
|
||||
};
|
||||
38
src/routes/brews/+page.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let data;
|
||||
|
||||
const brews = data?.brews || [];
|
||||
const path = (date: string) => '/brews/' + String(date);
|
||||
|
||||
const dateFormat = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const dateString = (date) => new Date(date * 1000).toLocaleDateString('no-NB', dateFormat);
|
||||
|
||||
</script>
|
||||
|
||||
<main class="page-content">
|
||||
<h1>Past brews</h1>
|
||||
|
||||
<ul>
|
||||
{#each brews as brew}
|
||||
<li><a href="{path(brew.date)}">{ brew.beer.name } av { brew.beer.brewery }</a> - {dateString(brew.date)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
main.page-content {
|
||||
|
||||
ul {
|
||||
margin-left: 1.2em;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: disc;
|
||||
line-height: 1.5;
|
||||
|
||||
a {
|
||||
color: #19A786;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
0
src/routes/brews/[date]/+error.svelte
Normal file
14
src/routes/brews/[date]/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import brews from '../../../brews.json';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (({ params }) => {
|
||||
const { date } = params;
|
||||
const brew = brews.find((b) => b?.date === date);
|
||||
|
||||
if (!brew) {
|
||||
throw error(404, 'Brew not found');
|
||||
}
|
||||
|
||||
return { brew };
|
||||
}) satisfies PageLoad;
|
||||
182
src/routes/brews/[date]/+page.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
let height: number;
|
||||
|
||||
// let brew = {
|
||||
// recipe: 'https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ',
|
||||
// bryggselv: 'https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter',
|
||||
// untapped: 'https://untappd.com/b/kinn-bryggeri-kveldsbris/695024'
|
||||
// }
|
||||
|
||||
export let data;
|
||||
let brew = data.brew;
|
||||
|
||||
const dateFormat = { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const dateString = new Date(Number(brew.date * 1000)).toLocaleDateString('no-NB', dateFormat);
|
||||
|
||||
const wizards = brew.by.join(', ');
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="desktop-only image-container" style="height: {height}px">
|
||||
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
|
||||
</div>
|
||||
|
||||
<div class="beer-container" bind:clientHeight="{height}">
|
||||
<h1>{brew.beer.name}</h1>
|
||||
|
||||
<div class="links">
|
||||
<a href="{brew.recipe}" target="_blank" rel="noreferrer">Recipe</a>
|
||||
<a href="{brew.order_page}" target="_blank" rel="noreferrer">Bryggselv</a>
|
||||
<a href="{brew.untapped}" target="_blank" rel="noreferrer">Untapped</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>Brygget:</b></td>
|
||||
<td>{ dateString }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Laget av:</b></td>
|
||||
<td>{ wizards }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Kategori:</b></td>
|
||||
<td>{ brew.beer.category }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Alkoholprosent:</b></td>
|
||||
<td>~ { brew.abv }%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mobile-only image-container">
|
||||
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
|
||||
</div>
|
||||
|
||||
<h3>Historie</h3>
|
||||
<p>
|
||||
I 1873 ble Tuborg Bryggeri grunnlagt av Carl Frederik Tietgen på Hellerud i Danmark. I 1970
|
||||
ble Tuborg Bryggeri en del av Carlsberg.
|
||||
</p>
|
||||
|
||||
<h3>Smak</h3>
|
||||
<p>
|
||||
Tuborg Sommerøl er en nordisk pilsner med en svært lys strågul farge. Aromaen er preget av
|
||||
fruktighet fra gjær, noter av blomster fra humle og lette noter av halm og honning fra malt.
|
||||
Ølet har en svært lett karakter med en lett maltsødme som er godt balansert mot en lav
|
||||
bitterhet.
|
||||
</p>
|
||||
|
||||
<h3>Mat</h3>
|
||||
<p>
|
||||
Tuborg Sommerøl egner seg godt til lys sommermat som pizza, pastaretter, salat, fisk og
|
||||
skalldyr.
|
||||
</p>
|
||||
|
||||
<p>Bruk av alkohol kan gi ulike skadevirkninger. Nærmere informasjon finner du her.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
section {
|
||||
@import url('https://fonts.googleapis.com/css2?family=Epilogue:wght@200;300;400;500;600;700;800&display=swap');
|
||||
font-family: 'Epilogue', sans-serif;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 1px;
|
||||
background-color: #93a4a0;
|
||||
padding: 3rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
width: 33.33%;
|
||||
float: left;
|
||||
padding: 5rem 1rem;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: top;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
}
|
||||
|
||||
.beer-container {
|
||||
background-color: rgba(215, 224, 223, 0.6);
|
||||
padding: 2rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
float: left;
|
||||
width: 66.66%;
|
||||
padding: 4rem 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2.4rem;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
|
||||
&:not(&:first-of-type) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
a::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
bottom: 1.5px;
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
list-style: none;
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 1px solid #bdc8ca;
|
||||
}
|
||||
|
||||
td {
|
||||
border-top: 1px solid #bdc8ca;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/routes/graphs/+page.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type IChartFrame from '$lib/interfaces/IChartFrame';
|
||||
|
||||
let DEFAULT_MINUTES = 10080;
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const temperatureData: IChartFrame[] = await getTemp(DEFAULT_MINUTES, fetch);
|
||||
const humidityData: IChartFrame[] = await getHumidity(DEFAULT_MINUTES, fetch);
|
||||
const pressureData: IChartFrame[] = await getPressure(DEFAULT_MINUTES, fetch);
|
||||
|
||||
return {
|
||||
temperatureData,
|
||||
humidityData,
|
||||
pressureData,
|
||||
DEFAULT_MINUTES
|
||||
};
|
||||
};
|
||||
|
||||
function getSensor(func: Function, minutes: number, fetch: Function) {
|
||||
const from: Date = new Date();
|
||||
const to = new Date(from.getTime() - minutes * 60 * 1000);
|
||||
const size = 40;
|
||||
|
||||
return func(from, to, size, fetch);
|
||||
}
|
||||
|
||||
function getTemp(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchTemperature, minutes, fetch);
|
||||
}
|
||||
|
||||
function getHumidity(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchHumidity, minutes, fetch);
|
||||
}
|
||||
|
||||
function getPressure(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchPressure, minutes, fetch);
|
||||
}
|
||||
128
src/routes/graphs/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
|
||||
import Graph from '$lib/components/Graph.svelte';
|
||||
import type IChartFrame from '$lib/interfaces/IChartFrame';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let temperatureData: IChartFrame[] = data?.temperatureData;
|
||||
let humidityData: IChartFrame[] = data?.temperatureData;
|
||||
let pressureData: IChartFrame[] = data?.temperatureData;
|
||||
let DEFAULT_MINUTES: number = data?.DEFAULT_MINUTES
|
||||
let minutes: number = DEFAULT_MINUTES;
|
||||
|
||||
const buttonMinutes = [{
|
||||
value: 15,
|
||||
name: 'Last 15 minutes'
|
||||
}, {
|
||||
value: 60,
|
||||
name: 'Last hour'
|
||||
}, {
|
||||
value: 1440,
|
||||
name: 'Last day'
|
||||
}, {
|
||||
value: 10080,
|
||||
name: 'Last week'
|
||||
}, {
|
||||
value: 43200,
|
||||
name: 'Last month'
|
||||
}, {
|
||||
value: 129600,
|
||||
name: 'Last 3 months'
|
||||
}, {
|
||||
value: 259200,
|
||||
name: 'Last 6 months'
|
||||
}, {
|
||||
value: 518400,
|
||||
name: 'Last year',
|
||||
}]
|
||||
|
||||
function reload(mins: number) {
|
||||
minutes = mins
|
||||
const from: Date = new Date();
|
||||
const to = new Date(from.getTime() - minutes * 60 * 1000);
|
||||
const size = 40;
|
||||
|
||||
fetchTemperature(from, to, size, window.fetch).then((resp) => (temperatureData = resp));
|
||||
fetchHumidity(from, to, size, window.fetch).then((resp) => (humidityData = resp));
|
||||
fetchPressure(from, to, size, window.fetch).then((resp) => (pressureData = resp));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <h1>Server: {emoji.emoji}</h1> -->
|
||||
|
||||
<input type="number" bind:value={minutes} on:input={() => reload(minutes)} />
|
||||
|
||||
<div class="button-wrapper">
|
||||
{#each buttonMinutes as button}
|
||||
<button on:click={() => reload(button.value)} class="{button.value === minutes ? 'selected' : ''}">{ button.name }</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="graphs">
|
||||
{#if temperatureData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={temperatureData} name="Temperature" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if humidityData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={humidityData} name="Humidity" beginAtZero={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if pressureData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={pressureData} name="Pressure" beginAtZero={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.graphs {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
width: 100%;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graphWrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
border-radius: 2rem;
|
||||
white-space: nowrap;
|
||||
font-size: 1.1rem;
|
||||
border: none;
|
||||
width: content;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.3rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background: var(--green);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: salmon;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
32
src/styles/media-queries.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
$tablet-width: 1200px;
|
||||
$mobile-width: 768px;
|
||||
|
||||
@mixin tablet {
|
||||
@media (min-width: #{$mobile-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin mobile {
|
||||
@media (max-width: #{$mobile-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin desktop {
|
||||
@media (min-width: #{$tablet-width + 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@include mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
@include tablet {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
35
src/styles/variables.css
Normal file
@@ -0,0 +1,35 @@
|
||||
:root {
|
||||
--background: white;
|
||||
--backdrop: #f5f5f7;
|
||||
--text-color: black;
|
||||
--red: #ff97a3;
|
||||
--blue: #9ad9ff;
|
||||
--header-height: 200px;
|
||||
|
||||
--color-transition-duration: 0.4s;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: pink;
|
||||
--backdrop: #202124;
|
||||
--text-color: white;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit; /* blue colors for links too */
|
||||
text-decoration: inherit; /* no underline */
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
1
src_route_history/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
85
src_route_history/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import * as d3 from 'd3';
|
||||
import { onMount } from 'svelte';
|
||||
import d3_timeseries from './lib';
|
||||
|
||||
function createRandomData(n, range, rand) {
|
||||
if (range == null) range = [0, 100];
|
||||
if (rand == null) rand = 1 / 20;
|
||||
|
||||
var num = range[0] + Math.floor(Math.random() * (range[1] - range[0]));
|
||||
var num2 = range[0] + Math.floor(Math.random() * (range[1] - range[0]));
|
||||
var num3 = num;
|
||||
var d = new Date('2013-01-01');
|
||||
var data = [];
|
||||
var rgen = d3.randomNormal(0, (range[1] - range[0]) * rand);
|
||||
for (var i = 0; i < n; i++) {
|
||||
data.push({
|
||||
date: d,
|
||||
n: num,
|
||||
n2: num2,
|
||||
n3: num3,
|
||||
ci_up: num3 * 1.05,
|
||||
ci_down: num3 * 0.95
|
||||
});
|
||||
d = new Date(d.getTime() + 1000 * 60 * 60 * 24);
|
||||
num = num + rgen();
|
||||
num3 = num + rgen() / 3;
|
||||
num = Math.min(Math.max(num, range[0]), range[1]);
|
||||
num2 = num2 + rgen();
|
||||
num2 = Math.min(Math.max(num2, range[0]), range[1]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
var data = createRandomData(80, [0, 1000], 0.01);
|
||||
// [{date:new Date('2013-01-01'),n:120,n3:200},...]
|
||||
|
||||
/*
|
||||
var chart = d3_timeseries()
|
||||
.addSerie(data, { x: 'date', y: 'n' }, { interpolate: 'monotone', color: '#333' })
|
||||
.width(820);
|
||||
*/
|
||||
|
||||
const svg = d3.create("svg")
|
||||
.attr("viewBox", [0, 0, width, height]);
|
||||
|
||||
const gradient = DOM.uid();
|
||||
|
||||
svg.append("g")
|
||||
.call(xAxis);
|
||||
|
||||
svg.append("g")
|
||||
.call(yAxis);
|
||||
|
||||
svg.append("linearGradient")
|
||||
.attr("id", gradient.id)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", height - margin.bottom)
|
||||
.attr("x2", 0)
|
||||
.attr("y2", margin.top)
|
||||
.selectAll("stop")
|
||||
.data(d3.ticks(0, 1, 10))
|
||||
.join("stop")
|
||||
.attr("offset", d => d)
|
||||
.attr("stop-color", color.interpolator());
|
||||
|
||||
svg.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", gradient)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("d", line);
|
||||
|
||||
const chart = svg.node();
|
||||
|
||||
onMount(() => chart('#chart'));
|
||||
</script>
|
||||
|
||||
<h1>Chart goes here</h1>
|
||||
<div id="chart"></div>
|
||||
|
||||
<p>Hopefully above 👆</p>
|
||||
620
src_route_history/lib.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
import * as d3 from 'd3';
|
||||
import { transition } from 'd3-transition';
|
||||
|
||||
var defaultColors = [
|
||||
'#a6cee3',
|
||||
'#ff7f00',
|
||||
'#b2df8a',
|
||||
'#1f78b4',
|
||||
'#fdbf6f',
|
||||
'#33a02c',
|
||||
'#cab2d6',
|
||||
'#6a3d9a',
|
||||
'#fb9a99',
|
||||
'#e31a1c',
|
||||
'#ffff99',
|
||||
'#b15928'
|
||||
];
|
||||
|
||||
// utils
|
||||
function functorkey(v) {
|
||||
return typeof v === 'function'
|
||||
? v
|
||||
: function(d) {
|
||||
return d[v];
|
||||
};
|
||||
}
|
||||
|
||||
function functorkeyscale(v, scale) {
|
||||
var f =
|
||||
typeof v === 'function'
|
||||
? v
|
||||
: function(d) {
|
||||
return d[v];
|
||||
};
|
||||
return function(d) {
|
||||
return scale(f(d));
|
||||
};
|
||||
}
|
||||
|
||||
function keyNotNull(k) {
|
||||
return function(d) {
|
||||
return d.hasOwnProperty(k) && d[k] !== null && !isNaN(d[k]);
|
||||
};
|
||||
}
|
||||
|
||||
function fk(v) {
|
||||
return function(d) {
|
||||
return d[v];
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
// default
|
||||
var height = 480;
|
||||
var width = 600;
|
||||
|
||||
var drawerHeight = 80;
|
||||
var drawerTopMargin = 10;
|
||||
var margin = { top: 10, bottom: 20, left: 30, right: 10 };
|
||||
|
||||
var series = [];
|
||||
|
||||
var yscale = d3.scaleLinear();
|
||||
var xscale = d3.scaleTime();
|
||||
yscale.label = '';
|
||||
xscale.label = '';
|
||||
|
||||
var brush = d3.brushX();
|
||||
|
||||
var svg, container, serieContainer, annotationsContainer, drawerContainer, mousevline;
|
||||
var fullxscale, tooltipDiv;
|
||||
|
||||
yscale.setformat = function(n) {
|
||||
return n.toLocaleString();
|
||||
};
|
||||
xscale.setformat = xscale.tickFormat();
|
||||
|
||||
// default tool tip function
|
||||
var _tipFunction = function(date, series) {
|
||||
var spans =
|
||||
'<table style="border:none">' +
|
||||
series
|
||||
.filter(function(d) {
|
||||
console.log('DDD:', d);
|
||||
return d.item !== undefined && d.item !== null;
|
||||
})
|
||||
.map(function(d) {
|
||||
return (
|
||||
'<tr><td style="color:' +
|
||||
d.options.color +
|
||||
'">' +
|
||||
d.options.label +
|
||||
' </td>' +
|
||||
'<td style="color:#333333;text-align:right">' +
|
||||
yscale.setformat(d.item[d.aes.y]) +
|
||||
'</td></tr>'
|
||||
);
|
||||
})
|
||||
.join('') +
|
||||
'</table>';
|
||||
|
||||
return '<h4>' + xscale.setformat(d3.timeDay(date)) + '</h4>' + spans;
|
||||
};
|
||||
|
||||
function createLines(serie) {
|
||||
// https://github.com/d3/d3-shape/blob/master/README.md#curves
|
||||
var aes = serie.aes;
|
||||
|
||||
if (!serie.options.interpolate) {
|
||||
serie.options.interpolate = 'linear';
|
||||
} else {
|
||||
// translate curvenames
|
||||
serie.options.interpolate =
|
||||
serie.options.interpolate == 'monotone'
|
||||
? 'monotoneX'
|
||||
: serie.options.interpolate == 'step-after'
|
||||
? 'stepAfter'
|
||||
: serie.options.interpolate == 'step-before'
|
||||
? 'stepBefore'
|
||||
: serie.options.interpolate;
|
||||
}
|
||||
// to uppercase for d3 curve name
|
||||
var curveName =
|
||||
'curve' + serie.options.interpolate[0].toUpperCase() + serie.options.interpolate.slice(1);
|
||||
serie.interpolationFunction = d3[curveName] || d3.curveLinear;
|
||||
|
||||
var line = d3
|
||||
.line()
|
||||
.x(functorkeyscale(aes.x, xscale))
|
||||
.y(functorkeyscale(aes.y, yscale))
|
||||
.curve(serie.interpolationFunction)
|
||||
.defined(keyNotNull(aes.y));
|
||||
|
||||
serie.line = line;
|
||||
|
||||
serie.options.label =
|
||||
serie.options.label || serie.options.name || serie.aes.label || serie.aes.y;
|
||||
|
||||
if (aes.ci_up && aes.ci_down) {
|
||||
var ciArea = d3
|
||||
.area()
|
||||
.x(functorkeyscale(aes.x, xscale))
|
||||
.y0(functorkeyscale(aes.ci_down, yscale))
|
||||
.y1(functorkeyscale(aes.ci_up, yscale))
|
||||
.curve(serie.interpolationFunction);
|
||||
serie.ciArea = ciArea;
|
||||
}
|
||||
|
||||
if (aes.diff) {
|
||||
serie.diffAreas = [
|
||||
d3
|
||||
.area()
|
||||
.x(functorkeyscale(aes.x, xscale))
|
||||
.y0(functorkeyscale(aes.y, yscale))
|
||||
.y1(function(d) {
|
||||
if (d[aes.y] > d[aes.diff]) return yscale(d[aes.diff]);
|
||||
return yscale(d[aes.y]);
|
||||
})
|
||||
.curve(serie.interpolationFunction),
|
||||
d3
|
||||
.area()
|
||||
.x(functorkeyscale(aes.x, xscale))
|
||||
.y1(functorkeyscale(aes.y, yscale))
|
||||
.y0(function(d) {
|
||||
if (d[aes.y] < d[aes.diff]) return yscale(d[aes.diff]);
|
||||
return yscale(d[aes.y]);
|
||||
})
|
||||
.curve(serie.interpolationFunction)
|
||||
];
|
||||
}
|
||||
|
||||
serie.find = function(date) {
|
||||
var bisect = d3.bisector(fk(aes.x)).left;
|
||||
var i = bisect(serie.data, date) - 1;
|
||||
if (i == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// look to far after serie is defined
|
||||
if (
|
||||
i == serie.data.length - 1 &&
|
||||
serie.data.length > 1 &&
|
||||
Number(date) - Number(serie.data[i][aes.x]) >
|
||||
Number(serie.data[i][aes.x]) - Number(serie.data[i - 1][aes.x])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return serie.data[i];
|
||||
};
|
||||
}
|
||||
|
||||
function drawSerie(serie) {
|
||||
if (!serie.linepath) {
|
||||
console.log(series);
|
||||
const sorted = [...series[0].data];
|
||||
sorted.sort((a, b) => (a.n > b.n ? 1 : -1));
|
||||
const min = sorted[0].n;
|
||||
const max = sorted[sorted.length - 1].n;
|
||||
console.log('max:', max);
|
||||
console.log('min:', min);
|
||||
const midean = (max + min) / 2;
|
||||
console.log('midean:', midean);
|
||||
|
||||
var linepath = serieContainer
|
||||
.append('path')
|
||||
.datum(serie.data.filter((e) => e.n <= midean))
|
||||
.attr('class', 'd3_timeseries line')
|
||||
.attr('d', serie.line)
|
||||
// .attr('stroke', serie.options.color)
|
||||
.attr('stroke', 'red')
|
||||
.attr('stroke-linecap', 'round')
|
||||
.attr('stroke-width', serie.options.width || 1.5)
|
||||
.attr('fill', 'none');
|
||||
|
||||
if (serie.options.dashed) {
|
||||
if (serie.options.dashed == true || serie.options.dashed == 'dashed') {
|
||||
serie['stroke-dasharray'] = '5,5';
|
||||
} else if (serie.options.dashed == 'long') {
|
||||
serie['stroke-dasharray'] = '10,10';
|
||||
} else if (serie.options.dashed == 'dot') {
|
||||
serie['stroke-dasharray'] = '2,4';
|
||||
} else {
|
||||
serie['stroke-dasharray'] = serie.options.dashed;
|
||||
}
|
||||
linepath.attr('stroke-dasharray', serie['stroke-dasharray']);
|
||||
}
|
||||
serie.linepath = linepath;
|
||||
// serie.hotLine = hotLine;
|
||||
|
||||
if (serie.ciArea) {
|
||||
serie.cipath = serieContainer
|
||||
.insert('path', ':first-child')
|
||||
.datum(serie.data)
|
||||
.attr('class', 'd3_timeseries ci-area')
|
||||
.attr('d', serie.ciArea)
|
||||
.attr('stroke', 'none')
|
||||
.attr('fill', serie.options.color)
|
||||
.attr('opacity', serie.options.ci_opacity || 0.3);
|
||||
}
|
||||
if (serie.diffAreas) {
|
||||
serie.diffpaths = serie.diffAreas.map(function(area, i) {
|
||||
var c = (serie.options.diff_colors ? serie.options.diff_colors : ['green', 'red'])[i];
|
||||
return serieContainer
|
||||
.insert('path', function() {
|
||||
return linepath.node();
|
||||
})
|
||||
.datum(serie.data)
|
||||
.attr('class', 'd3_timeseries diff-area')
|
||||
.attr('d', area)
|
||||
.attr('stroke', 'none')
|
||||
.attr('fill', c)
|
||||
.attr('opacity', serie.options.diff_opacity || 0.5);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
serie.linepath.attr('d', serie.line);
|
||||
serie.linepath.attr('d', serie.hotLine);
|
||||
if (serie.ciArea) {
|
||||
serie.cipath.attr('d', serie.ciArea);
|
||||
}
|
||||
if (serie.diffAreas) {
|
||||
serie.diffpaths[0].attr('d', serie.diffAreas[0]);
|
||||
serie.diffpaths[1].attr('d', serie.diffAreas[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatefocusRing(xdate) {
|
||||
var s = annotationsContainer.selectAll('circle.d3_timeseries.focusring');
|
||||
|
||||
if (xdate == null) {
|
||||
s = s.data([]);
|
||||
} else {
|
||||
s = s.data(
|
||||
series
|
||||
.map(function(s) {
|
||||
return { x: xdate, item: s.find(xdate), aes: s.aes, color: s.options.color };
|
||||
})
|
||||
.filter(function(d) {
|
||||
return (
|
||||
d.item !== undefined &&
|
||||
d.item !== null &&
|
||||
d.item[d.aes.y] !== null &&
|
||||
!isNaN(d.item[d.aes.y])
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const t = transition().duration(50);
|
||||
/*
|
||||
.attr('cx', function(d) {
|
||||
console.log('aDDD:', d);
|
||||
|
||||
return xscale(d.item[d.aes.n]);
|
||||
})
|
||||
.attr('cy', function(d) {
|
||||
return yscale(d.item[d.aes.date]);
|
||||
});
|
||||
*/
|
||||
|
||||
s.transition(t);
|
||||
|
||||
s.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'd3_timeseries focusring')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('r', 5)
|
||||
.attr('stroke', fk('color'));
|
||||
|
||||
s.exit().remove();
|
||||
}
|
||||
|
||||
function updateTip(xdate) {
|
||||
if (xdate == null) {
|
||||
tooltipDiv.style('opacity', 0);
|
||||
} else {
|
||||
var s = series.map(function(s) {
|
||||
return { item: s.find(xdate), aes: s.aes, options: s.options };
|
||||
});
|
||||
|
||||
tooltipDiv
|
||||
.style('opacity', 0.9)
|
||||
.style('left', margin.left + 5 + xscale(xdate) + 'px')
|
||||
.style('top', '0px')
|
||||
.html(_tipFunction(xdate, s));
|
||||
}
|
||||
}
|
||||
|
||||
function drawMiniDrawer() {
|
||||
var smallyscale = yscale.copy().range([drawerHeight - drawerTopMargin, 0]);
|
||||
var serie = series[0];
|
||||
var line = d3
|
||||
.line()
|
||||
.x(functorkeyscale(serie.aes.x, fullxscale))
|
||||
.y(functorkeyscale(serie.aes.y, smallyscale))
|
||||
.curve(serie.interpolationFunction)
|
||||
.defined(keyNotNull(serie.aes.y));
|
||||
var linepath = drawerContainer
|
||||
.insert('path', ':first-child')
|
||||
.datum(serie.data)
|
||||
.attr('class', 'd3_timeseries.line')
|
||||
.attr('transform', 'translate(0,' + drawerTopMargin + ')')
|
||||
.attr('d', line)
|
||||
.attr('stroke', serie.options.color)
|
||||
.attr('stroke-width', serie.options.width || 1.5)
|
||||
.attr('fill', 'none');
|
||||
if (serie.hasOwnProperty('stroke-dasharray')) {
|
||||
linepath.attr('stroke-dasharray', serie['stroke-dasharray']);
|
||||
}
|
||||
}
|
||||
|
||||
function mouseMove() {
|
||||
var x = d3.pointer(container.node())[0];
|
||||
x = xscale.invert(x);
|
||||
mousevline.datum({ x: x, visible: true });
|
||||
mousevline.update();
|
||||
updatefocusRing(x);
|
||||
updateTip(x);
|
||||
}
|
||||
function mouseOut() {
|
||||
mousevline.datum({ x: null, visible: false });
|
||||
mousevline.update();
|
||||
updatefocusRing(null);
|
||||
updateTip(null);
|
||||
}
|
||||
|
||||
var chart = function(elem) {
|
||||
// compute mins max on all series
|
||||
series = series.map(function(s) {
|
||||
var extent = d3.extent(s.data.map(functorkey(s.aes.y)));
|
||||
s.min = extent[0];
|
||||
s.max = extent[1];
|
||||
extent = d3.extent(s.data.map(functorkey(s.aes.x)));
|
||||
s.dateMin = extent[0];
|
||||
s.dateMax = extent[1];
|
||||
return s;
|
||||
});
|
||||
|
||||
// set scales
|
||||
|
||||
yscale
|
||||
.range([height - margin.top - margin.bottom - drawerHeight - drawerTopMargin, 0])
|
||||
.domain([d3.min(series.map(fk('min'))), d3.max(series.map(fk('max')))])
|
||||
.nice();
|
||||
|
||||
xscale
|
||||
.range([0, width - margin.left - margin.right])
|
||||
.domain([d3.min(series.map(fk('dateMin'))), d3.max(series.map(fk('dateMax')))])
|
||||
.nice();
|
||||
|
||||
// if user specify domain
|
||||
if (yscale.fixedomain) {
|
||||
// for showing 0 :
|
||||
// chart.addSerie(...)
|
||||
// .yscale.domain([0])
|
||||
if (yscale.fixedomain.length == 1) {
|
||||
yscale.fixedomain.push(yscale.domain()[1]);
|
||||
}
|
||||
yscale.domain(yscale.fixedomain);
|
||||
}
|
||||
|
||||
if (xscale.fixedomain) {
|
||||
xscale.domain(yscale.fixedomain);
|
||||
}
|
||||
|
||||
fullxscale = xscale.copy();
|
||||
|
||||
// create svg
|
||||
svg = d3.select(elem).append('svg').attr('width', width).attr('height', height);
|
||||
|
||||
// clipping for scrolling in focus area
|
||||
svg
|
||||
.append('defs')
|
||||
.append('clipPath')
|
||||
.attr('id', 'clip')
|
||||
.append('rect')
|
||||
.attr('width', width - margin.left - margin.right)
|
||||
.attr('height', height - margin.bottom - drawerHeight - drawerTopMargin)
|
||||
.attr('y', -margin.top);
|
||||
|
||||
// container for focus area
|
||||
container = svg
|
||||
.insert('g', 'rect.mouse-catch')
|
||||
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
|
||||
.attr('clip-path', 'url(#clip)');
|
||||
|
||||
serieContainer = container.append('g');
|
||||
annotationsContainer = container.append('g');
|
||||
|
||||
// mini container at the bottom
|
||||
drawerContainer = svg
|
||||
.append('g')
|
||||
.attr(
|
||||
'transform',
|
||||
'translate(' + margin.left + ',' + (height - drawerHeight - margin.bottom) + ')'
|
||||
);
|
||||
|
||||
// vertical line moving with mouse tip
|
||||
mousevline = svg.append('g').datum({
|
||||
x: new Date(),
|
||||
visible: false
|
||||
});
|
||||
mousevline
|
||||
.append('line')
|
||||
.attr('x1', 0)
|
||||
.attr('x2', 0)
|
||||
.attr('y1', yscale.range()[0])
|
||||
.attr('y2', yscale.range()[1])
|
||||
.attr('class', 'd3_timeseries mousevline');
|
||||
// update mouse vline
|
||||
mousevline.update = function() {
|
||||
this.attr('transform', function(d) {
|
||||
return 'translate(' + (margin.left + xscale(d.x)) + ',' + margin.top + ')';
|
||||
}).style('opacity', function(d) {
|
||||
return d.visible ? 1 : 0;
|
||||
});
|
||||
};
|
||||
mousevline.update();
|
||||
|
||||
var xAxis = d3.axisBottom().scale(xscale).tickFormat(xscale.setformat);
|
||||
var yAxis = d3.axisLeft().scale(yscale).tickFormat(yscale.setformat);
|
||||
|
||||
brush
|
||||
.extent([
|
||||
[fullxscale.range()[0], 0],
|
||||
[fullxscale.range()[1], drawerHeight - drawerTopMargin]
|
||||
])
|
||||
|
||||
.on('brush', () => {
|
||||
let selection = d3.event.selection;
|
||||
|
||||
xscale.domain(selection.map(fullxscale.invert, fullxscale));
|
||||
|
||||
series.forEach(drawSerie);
|
||||
svg.select('.focus.x.axis').call(xAxis);
|
||||
mousevline.update();
|
||||
updatefocusRing();
|
||||
})
|
||||
|
||||
.on('end', () => {
|
||||
let selection = d3.event.selection;
|
||||
if (selection === null) {
|
||||
xscale.domain(fullxscale.domain());
|
||||
|
||||
series.forEach(drawSerie);
|
||||
svg.select('.focus.x.axis').call(xAxis);
|
||||
mousevline.update();
|
||||
updatefocusRing();
|
||||
}
|
||||
});
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.attr('class', 'd3_timeseries focus x axis')
|
||||
.attr(
|
||||
'transform',
|
||||
'translate(' +
|
||||
margin.left +
|
||||
',' +
|
||||
(height - margin.bottom - drawerHeight - drawerTopMargin) +
|
||||
')'
|
||||
)
|
||||
.call(xAxis);
|
||||
|
||||
drawerContainer
|
||||
.append('g')
|
||||
.attr('class', 'd3_timeseries x axis')
|
||||
.attr('transform', 'translate(0,' + drawerHeight + ')')
|
||||
.call(xAxis);
|
||||
|
||||
drawerContainer
|
||||
.append('g')
|
||||
.attr('class', 'd3_timeseries brush')
|
||||
.call(brush)
|
||||
.attr('transform', `translate(0, ${drawerTopMargin})`)
|
||||
.attr('height', drawerHeight - drawerTopMargin);
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.attr('class', 'd3_timeseries y axis')
|
||||
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
|
||||
.call(yAxis)
|
||||
.append('text')
|
||||
.attr('transform', 'rotate(-90)')
|
||||
.attr('x', -margin.top - d3.mean(yscale.range()))
|
||||
.attr('dy', '.71em')
|
||||
.attr('y', -margin.left + 5)
|
||||
.style('text-anchor', 'middle')
|
||||
.text(yscale.label);
|
||||
|
||||
// catch event for mouse tip
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('width', width)
|
||||
.attr('class', 'd3_timeseries mouse-catch')
|
||||
.attr('height', height - drawerHeight)
|
||||
// .style('fill','green')
|
||||
.style('opacity', 0)
|
||||
.on('mousemove', mouseMove)
|
||||
.on('mouseout', mouseOut);
|
||||
|
||||
tooltipDiv = d3
|
||||
.select(elem)
|
||||
.style('position', 'relative')
|
||||
.append('div')
|
||||
.attr('class', 'd3_timeseries tooltip')
|
||||
.style('opacity', 0);
|
||||
|
||||
series.forEach(createLines);
|
||||
series.forEach(drawSerie);
|
||||
drawMiniDrawer();
|
||||
};
|
||||
|
||||
chart.width = function(_) {
|
||||
if (!arguments.length) return width;
|
||||
width = _;
|
||||
return chart;
|
||||
};
|
||||
|
||||
chart.height = function(_) {
|
||||
if (!arguments.length) return height;
|
||||
height = _;
|
||||
return chart;
|
||||
};
|
||||
|
||||
chart.margin = function(_) {
|
||||
if (!arguments.length) return margin;
|
||||
margin = _;
|
||||
return chart;
|
||||
};
|
||||
// accessors for margin.left(), margin.right(), margin.top(), margin.bottom()
|
||||
// UNDEFINED
|
||||
/*
|
||||
d3.keys(margin).forEach(function(k) {
|
||||
chart.margin[k] = function(_) {
|
||||
if (!arguments.length) return margin[k];
|
||||
margin[k] = _;
|
||||
return chart;
|
||||
};
|
||||
});
|
||||
*/
|
||||
|
||||
// scales accessors
|
||||
var scaleGetSet = function(scale) {
|
||||
return {
|
||||
tickFormat: function(_) {
|
||||
if (!arguments.length) return scale.setformat;
|
||||
scale.setformat = _;
|
||||
return chart;
|
||||
},
|
||||
label: function(_) {
|
||||
if (!arguments.length) return scale.label;
|
||||
scale.label = _;
|
||||
return chart;
|
||||
},
|
||||
domain: function(_) {
|
||||
if (!arguments.length && scale.fixedomain) return scale.fixedomain;
|
||||
if (!arguments.length) return null;
|
||||
scale.fixedomain = _;
|
||||
return chart;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
chart.yscale = scaleGetSet(yscale);
|
||||
chart.xscale = scaleGetSet(xscale);
|
||||
|
||||
chart.addSerie = function(data, aes, options) {
|
||||
if (!data && series.length > 0) data = series[0].data;
|
||||
if (!options.color) options.color = defaultColors[series.length % defaultColors.length];
|
||||
series.push({ data: data, aes: aes, options: options });
|
||||
return chart;
|
||||
};
|
||||
|
||||
return chart;
|
||||
}
|
||||
|
||||
export default main;
|
||||
BIN
static/fonts/Nunito-Bold.ttf
Normal file
BIN
static/fonts/Nunito-Bold.woff
Normal file
BIN
static/fonts/Nunito-Italic.ttf
Normal file
BIN
static/fonts/Nunito-Italic.woff
Normal file
BIN
static/fonts/Nunito-Medium.woff
Normal file
BIN
static/fonts/Nunito-Regular.eot
Normal file
BIN
static/fonts/Nunito-Regular.ttf
Normal file
BIN
static/fonts/Overpass-Regular.ttf
Normal file
BIN
static/fonts/Roboto-Bold.eot
Normal file
BIN
static/fonts/Roboto-Bold.ttf
Normal file
BIN
static/fonts/Roboto-Bold.woff
Normal file
BIN
static/fonts/Roboto-Italic.eot
Normal file
BIN
static/fonts/Roboto-Italic.ttf
Normal file
BIN
static/fonts/Roboto-Italic.woff
Normal file
BIN
static/fonts/Roboto-Light.ttf
Normal file
BIN
static/fonts/Roboto-Regular.eot
Normal file
BIN
static/fonts/Roboto-Regular.ttf
Normal file
BIN
static/fonts/Roboto-Regular.woff
Normal file
45
static/global.css
Normal file
@@ -0,0 +1,45 @@
|
||||
body {
|
||||
font-family: 'Roboto';
|
||||
background-color: var(--backdrop);
|
||||
color: var(--text-color);
|
||||
|
||||
transition: background-color var(--color-transition-duration) ease-in-out,
|
||||
border-color var(--color-transition-duration) ease-in-out,
|
||||
color var(--color-transition-duration) ease-in-out,
|
||||
font-family var(--color-transition-duration) ease-in-out;
|
||||
}
|
||||
|
||||
/* Nunito regular */
|
||||
@font-face {
|
||||
font-family: 'Nunito';
|
||||
src: url('/fonts/Nunito-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('/fonts/Nunito-Regular.woff') format('woff'),
|
||||
url('/fonts/Nunito-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Overpass regular */
|
||||
@font-face {
|
||||
font-family: 'Overpass';
|
||||
src: url('/fonts/Overpass-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Roboto regular */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('/fonts/Roboto-Regular.woff') format('woff'),
|
||||
url('/fonts/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
BIN
static/images/finest_fuck-yeah-IPA.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
static/images/finest_lazy-days.jpeg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
static/images/finest_love-in-a-canoe.jpeg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
static/images/finest_utepils.jpeg
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
static/images/helles_tysk-lager.jpeg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
static/images/kinn_kveldsbris.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
static/stream.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
41
static/variables.css
Normal file
@@ -0,0 +1,41 @@
|
||||
:root {
|
||||
--background: white;
|
||||
--backdrop: #f5f5f7;
|
||||
--text-color: black;
|
||||
--red: #ff97a3;
|
||||
--blue: #9ad9ff;
|
||||
--green: #19a786;
|
||||
|
||||
--header-height: 70px;
|
||||
|
||||
--color-transition-duration: 0.4s;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: black;
|
||||
--backdrop: #202124;
|
||||
--text-color: white;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit; /* blue colors for links too */
|
||||
text-decoration: inherit; /* no underline */
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import preprocess from 'svelte-preprocess';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: preprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
|
||||
// hydrate the <div id="svelte"> element in src/app.html
|
||||
target: '#svelte'
|
||||
csrf: {
|
||||
checkOrigin: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
{
|
||||
// "extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "es2020",
|
||||
"lib": ["es2020", "DOM"],
|
||||
"target": "es2020",
|
||||
/**
|
||||
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
|
||||
to enforce using \`import type\` instead of \`import\` for Types.
|
||||
*/
|
||||
"importsNotUsedAsValues": "error",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
To have warnings/errors of the Svelte compiler at the correct position,
|
||||
enable source maps by default.
|
||||
*/
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"paths": {
|
||||
"$lib": ["src/lib"],
|
||||
"$lib/*": ["src/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
|
||||
8
vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import type { UserConfig } from 'vite';
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [sveltekit()]
|
||||
};
|
||||
|
||||
export default config;
|
||||