Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Weather forecast sidebar component #175

Merged
merged 8 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/translations/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ def process_dir(_dir, _output, _keys):
("return_home", ["ui.dialogs.more_info_control.vacuum.return_home"]),
("start_pause", ["ui.dialogs.more_info_control.vacuum.start_pause"]),
("battery", ["ui.dialogs.entity_registry.editor.device_classes.binary_sensor.battery"]),
("weather_forecast", ["ui.panel.lovelace.editor.card.weather-forecast.name"]),
("count", ["ui.panel.config.automation.editor.actions.type.repeat.count"]),
("set_white", ["ui.dialogs.more_info_control.light.set_white"]),
("vacuum_commands", ["ui.dialogs.more_info_control.vacuum.commands"]),
("target", ["ui.card.water_heater.target"]),
Expand Down
12 changes: 12 additions & 0 deletions src/lib/Modal/SidebarItemConfig.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import Divider from '$lib/Sidebar/Divider.svelte';
import Navigate from '$lib/Sidebar/Navigate.svelte';
import Weather from '$lib/Sidebar/Weather.svelte';
import WeatherForecast from '$lib/Sidebar/WeatherForecast.svelte';
import Iframe from '$lib/Sidebar/Iframe.svelte';
import Image from '$lib/Sidebar/Image.svelte';
import Camera from '$lib/Sidebar/Camera.svelte';
Expand Down Expand Up @@ -177,6 +178,14 @@
entity_id: 'weather.openweathermap'
}
},
{
id: 'weatherforecast',
type: $lang('weather_forecast'),
component: WeatherForecast,
props: {
entity_id: 'weather.forecast_home'
}
},
{
id: 'navigate',
type: $lang('navigate'),
Expand Down Expand Up @@ -227,6 +236,9 @@
case 'weather':
openModal(() => import('$lib/Modal/WeatherConfig.svelte'), { sel });
break;
case 'weatherforecast':
openModal(() => import('$lib/Modal/WeatherForecastConfig.svelte'), { sel });
break;
case 'camera':
openModal(() => import('$lib/Modal/CameraConfig.svelte'), {
sel,
Expand Down
118 changes: 118 additions & 0 deletions src/lib/Modal/WeatherForecastConfig.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script lang="ts">
import { states, dashboard, lang, record } from '$lib/Stores';
import { onDestroy } from 'svelte';
import WeatherForecast from '$lib/Sidebar/WeatherForecast.svelte';
import Select from '$lib/Components/Select.svelte';
import ConfigButtons from '$lib/Modal/ConfigButtons.svelte';
import Modal from '$lib/Modal/Index.svelte';
import { updateObj } from '$lib/Utils';
import type { HassEntity } from 'home-assistant-js-websocket';
import type { WeatherForecastItem } from '$lib/Types';

export let isOpen: boolean;
export let sel: WeatherForecastItem;

let number_of_items = sel?.number_of_items ?? 7;

let numberElement: HTMLInputElement;

let entity: HassEntity;
$: {
if (sel?.entity_id) {
if ($states?.[sel?.entity_id]?.last_updated !== entity?.last_updated) {
entity = $states?.[sel?.entity_id];
}
}
}

const iconOptions = [
{ id: 'materialsymbolslight', name: 'materialsymbolslight' },
{ id: 'meteocons', name: 'meteocons' },
{ id: 'weathericons', name: 'weather icons' }
];

$: weatherStates = Object.keys(
Object.fromEntries(Object.entries($states)
.filter(([key, value]) => key.startsWith('weather.') && value?.attributes?.forecast))
).sort()
.map((key) => ({ id: key, label: key }));

$: range = {
min: 1,
max: Math.min(entity?.attributes?.forecast?.length ?? 7, 7)
};

function minMax(key: string | number | undefined) {
return Math.min(Math.max(parseInt(key as string), range.min), range.max);
}

function handleNumberRange(event: any) {
console.log(event?.target?.value);
const value = minMax(event?.target?.value);
set('number_of_items', value);
if (numberElement) numberElement.value = String(value);
}

function set(key: string, event?: any) {
sel = updateObj(sel, key, event);
$dashboard = $dashboard;
}

onDestroy(() => $record());
</script>

{#if isOpen}
<Modal>
<h1 slot="title">{$lang('weather_forecast')}</h1>

<h2>{$lang('preview')}</h2>

<div class="preview">
<WeatherForecast
entity_id={sel?.entity_id}
icon_pack={sel?.icon_pack}
number_of_items={sel?.number_of_items}
/>
</div>

<h2>{$lang('entity')}</h2>

{#if weatherStates}
<Select
customItems={true}
options={weatherStates}
placeholder={$lang('entity')}
value={sel?.entity_id}
on:change={(event) => set('entity_id', event)}
/>
{/if}

<h2>{$lang('icon')}</h2>

{#if iconOptions}
<Select
options={iconOptions}
placeholder={$lang('icon')}
value={sel?.icon_pack}
on:change={(event) => set('icon_pack', event)}
/>
{/if}

<h2>{$lang('count')}</h2>

{#if weatherStates}
<input
type="number"
class="input"
bind:value={number_of_items}
bind:this={numberElement}
min={range.min}
max={range.max}
on:change={handleNumberRange}
autocomplete="off"
/>
{/if}

<ConfigButtons {sel} />
</Modal>
{/if}
18 changes: 17 additions & 1 deletion src/lib/Sidebar/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
let Time: typeof import('$lib/Sidebar/Time.svelte');
let Timer: typeof import('$lib/Sidebar/Timer.svelte');
let Weather: typeof import('$lib/Sidebar/Weather.svelte');
let WeatherForecast: typeof import('$lib/Sidebar/WeatherForecast.svelte');

const importsMap = {
bar: () => import('$lib/Sidebar/Bar.svelte').then((module) => (Bar = module)),
Expand All @@ -45,7 +46,9 @@
template: () => import('$lib/Sidebar/Template.svelte').then((module) => (Template = module)),
time: () => import('$lib/Sidebar/Time.svelte').then((module) => (Time = module)),
timer: () => import('$lib/Sidebar/Timer.svelte').then((module) => (Timer = module)),
weather: () => import('$lib/Sidebar/Weather.svelte').then((module) => (Weather = module))
weather: () => import('$lib/Sidebar/Weather.svelte').then((module) => (Weather = module)),
weatherforecast: () =>
import('$lib/Sidebar/WeatherForecast.svelte').then((module) => (WeatherForecast = module))
};

$: if ($dashboard?.sidebar) importComponents();
Expand Down Expand Up @@ -114,6 +117,8 @@
openModal(() => import('$lib/Modal/TimerConfig.svelte'), { sel });
} else if (sel?.type === 'weather') {
openModal(() => import('$lib/Modal/WeatherConfig.svelte'), { sel });
} else if (sel?.type === 'weatherforecast') {
openModal(() => import('$lib/Modal/WeatherForecastConfig.svelte'), { sel });
} else {
openModal(() => import('$lib/Modal/SidebarItemConfig.svelte'), { sel });

Expand Down Expand Up @@ -321,6 +326,17 @@
show_apparent={item?.show_apparent}
/>
</button>

<!-- WEATHER FORECAST -->
{:else if WeatherForecast && item?.type === 'weatherforecast'}
<button on:click={() => handleClick(item?.id)}>
<svelte:component
this={WeatherForecast.default}
entity_id={item?.entity_id}
icon_pack={item?.icon_pack}
number_of_items={item?.number_of_items}
/>
</button>
{/if}
</div>
{/each}
Expand Down
155 changes: 155 additions & 0 deletions src/lib/Sidebar/WeatherForecast.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script lang="ts">
import { states, selectedLanguage, lang } from '$lib/Stores';
import { iconMapMaterialSymbolsLight, iconMapMeteocons, iconMapWeatherIcons } from '$lib/Weather';
import type { WeatherIconSet, WeatherIconConditions, WeatherIconMapping } from '$lib/Weather';
import type { HassEntity } from 'home-assistant-js-websocket';
import Icon from '@iconify/svelte';

export let entity_id: string | undefined;
export let icon_pack: string | undefined;
export let number_of_items: number | undefined;

let entity: HassEntity;
$: {
if (entity_id) {
if ($states?.[entity_id]?.last_updated !== entity?.last_updated) {
entity = $states?.[entity_id];
}
}
}

$: entity_state = entity?.state;
$: attributes = entity?.attributes;

let iconSet: WeatherIconSet;
$: {
if (icon_pack === 'materialsymbolslight') {
iconSet = iconMapMaterialSymbolsLight;
} else if (icon_pack === 'meteocons') {
iconSet = iconMapMeteocons;
} else if (icon_pack === 'weathericons') {
iconSet = iconMapWeatherIcons;
} else {
iconSet = iconMapMeteocons;
}
}

// Because config may not include number_of_items, and some forecasts proviode 48 datapoints, we need to ensure it's correct
$: calculated = Math.min(number_of_items ?? 7, 7)

interface Forecast {
condition: string;
icon: WeatherIconMapping;
date: string;
temperature: number;
}
let forecast: Forecast[]
$: forecast = entity?.attributes?.forecast?.slice(0, calculated).map(function (item: any) {
let icon: WeatherIconMapping =
iconSet.conditions[item?.condition as keyof WeatherIconConditions];
let x: Forecast = {
condition: item?.condition,
icon: icon,
date: item?.datetime,
temperature: item?.temperature
};

return x;
});

// Different forecast providers choose different intervals, we need to figure out display based on this
$: forecast_diff = ((new Date(forecast?.[1]?.date)).valueOf() - (new Date(forecast?.[0]?.date)).valueOf()) / 3600000
</script>

{#if entity_state}
<div class="container">
{#each forecast as forecast, i}
<div class="item">
<div class="day">
{#if forecast_diff < 24}
{new Intl.DateTimeFormat($selectedLanguage, { hour: 'numeric' }).format(
new Date(forecast.date)
)}
{:else}
{new Intl.DateTimeFormat($selectedLanguage, { weekday: 'short' }).format(
new Date(forecast.date)
)}
{/if}
</div>

{#if forecast.icon.local}
<icon class="icon">
<img
src={`${forecast.icon.icon_variant_day}.svg`}
alt={entity_state}
width="100%"
height="100%"
/>
</icon>
{:else}
<Icon class="icon" icon={forecast.icon.icon_variant_day} width="100%" height="100%"></Icon>
{/if}

<div class="temp">
{Math.round(forecast.temperature)}{attributes?.temperature_unit || '°'}
</div>
</div>
{/each}
</div>
{:else}
<div class="container-empty">
{$lang('weather_forecast')}
</div>
{/if}

<style>
.item {
display: grid;
grid-column-gap: 0px;
grid-row-gap: 0px;
grid-template-areas:
'day day'
'icon'
'temp temp';
text-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1);
text-overflow: ellipsis;
overflow: hidden;
width: 3.6rem;
}

.container {
padding: var(--theme-sidebar-item-padding);
display: flex;
text-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1);
text-overflow: ellipsis;
overflow: hidden;
justify-content: space-between;
}

.day {
grid-area: 'day';
justify-content: center;
display: flex;
width: 3.6rem;
}

.icon {
grid-area: 'icon';
width: 3.6rem;
height: 3.6rem;
display: flex;
text-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1);
justify-content: center;
transform-origin: right;
}

.temp {
grid-area: 'temp';
justify-content: center;
display: flex;
white-space: nowrap;
width: 3.6rem;
overflow: hidden;
text-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1);
}
</style>
10 changes: 10 additions & 0 deletions src/lib/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type SidebarItem = BarItem &
TemplateItem &
TimeItem &
WeatherItem &
WeatherForecastItem &
DividerItem;

export interface BarItem {
Expand Down Expand Up @@ -198,3 +199,12 @@ export interface WeatherItem {
extra_sensor_icon?: string;
show_apparent?: boolean;
}

export interface WeatherForecastItem {
type?: string;
id?: number;
entity_id?: string;
state?: string;
icon_pack?: string;
number_of_items?: number;
}
Loading