// Lit import { LitElement, html, CSSResultGroup, TemplateResult, css, PropertyValues, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import tinycolor from 'tinycolor2'; // Custom Card helpers import { FrontendLocaleData, formatDateShort, formatTime } from 'custom-card-helpers'; // Chart.js import { Chart, ChartData, ChartOptions, Plugin, ScriptableLineSegmentContext } from 'chart.js/auto'; // DateTime import { DateTime } from 'luxon'; // Local imports import { CHART_COLOR, CHART_DATA } from '../const'; import { LunarPhaseCard } from '../lunar-phase-card'; import { FILL_COLORS, HA as HomeAssistant } from '../types'; import extract_color from '../utils/extract_color'; import { hexToRgba } from '../utils/helpers'; import { Moon } from '../utils/moon'; // Styles import styles from '../css/style.css'; @customElement('lunar-horizon-dynamic') export class LunarHorizonDynamic extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) moon!: Moon; @property({ attribute: false }) card!: LunarPhaseCard; @property({ type: Number }) public cardWidth!: number; @state() public fillColors!: FILL_COLORS; @state() dynamicChart!: Chart; @state() tinyColor = tinycolor; protected async firstUpdated(): Promise { this.fillColors = await this.extractColorData(); await new Promise((resolve) => setTimeout(resolve, 0)); this.initChart(); } protected shouldUpdate(_changedProperties: PropertyValues): boolean { if (_changedProperties.has('moon')) { if (this.dynamicChart) { this.dynamicChart.data = this.chartData; this.dynamicChart.update('none'); } } return true; } protected updated(_changedProperties: PropertyValues): void { if (!this.card.config || !this.moon) return; if (_changedProperties.has('cardWidth')) { if (this.dynamicChart) { this.dynamicChart.resize(this.cardWidth, this.cardHeight); } } } get cardHeight(): number { let height = this.cardWidth * 0.5; height = this.card.config.hide_buttons ? height : height - 48; return height; } static get styles(): CSSResultGroup { return [ styles, css` #horizon-dynamic-chart { display: block; position: relative; margin: 0 auto; width: 100%; height: 100%; max-width: 1800px; box-sizing: border-box; border-radius: inherit; overflow: hidden; } #blur-overlay { position: absolute; bottom: 0; left: 0; -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); background: transparent !important; width: 100%; height: 60%; pointer-events: none; overflow: hidden; z-index: 0; isolation: isolate; box-sizing: border-box; border-radius: 24px; will-change: backdrop-filter; margin: -2px; } #dynamic-chart { width: 100% !important; height: 100% !important; position: relative; z-index: 2; } `, ]; } get _locale(): FrontendLocaleData { return this.moon.locale; } get _date(): Date { return this.moon._dynamicDate; } get todayData() { return this.moon._dynamicChartData; } get chartData(): ChartData { return this._getChartData(); } get chartOptions(): ChartOptions { return this._getChartOptions(); } get chartPlugins(): Plugin[] { const expandChartArea = this._expandChartArea(); const nowPosition = this._nowPosition(); const midnightPositon = this._midnightPosition(); const timeMarkers = this._timesMarkersPlugin(); return [nowPosition, midnightPositon, timeMarkers, expandChartArea]; } private get CSS_COLOR() { const cssColors = getComputedStyle(this) as CSSStyleDeclaration; const property = (name: string) => cssColors.getPropertyValue(name).trim(); return { PRIMARY_TEXT: property('--lunar-card-label-font-color'), SECONDARY_TEXT: property('--secondary-text-color'), DEFAULT_PRIMARY_COLOR: property('--primary-color'), MOON_LINE_LIGHT: property('--lunar-fill-line-bellow-color'), FILL_BELLOW: property('--lunar-fill-bellow-color'), fillColor: property('--lunar-fill-color'), fillBelowColor: property('--lunar-fill-bellow-color'), fillBelowLineColor: property('--lunar-fill-line-bellow-color'), }; } private initChart(): void { if (this.dynamicChart) { this.dynamicChart.destroy(); } const data = this.chartData; const options = this.chartOptions; const plugins = this.chartPlugins; const ctx = this.shadowRoot!.getElementById('dynamic-chart') as HTMLCanvasElement; if (!ctx) return; ctx.addEventListener('touchstart', this._onChartTouchStart.bind(this), { passive: false }); ctx.addEventListener('touchmove', this._onChartTouchStart.bind(this), { passive: false }); ctx.addEventListener('touchend', this._onChartTouchEnd.bind(this)); this.dynamicChart = new Chart(ctx, { type: 'line', data: data, options: { ...options, }, plugins: plugins, }); } private _onChartTouchStart(event: TouchEvent): void { event.preventDefault(); } private _onChartTouchEnd(event: TouchEvent): void { const touch = event.changedTouches[0]; const canvas = this.shadowRoot!.getElementById('dynamic-chart') as HTMLCanvasElement; const chart = this.dynamicChart; if (touch && canvas && chart) { chart?.tooltip?.setActiveElements([], { x: 0, y: 0 }); chart?.setActiveElements([]); chart?.update(); } } protected render(): TemplateResult { const useBackground = this.card.config.show_background; return html`
${useBackground ? html`
` : nothing}
`; } private _getChartData(): ChartData { const isBackground = this.card.config.show_background; const SHARED_OPTIONS = { pointRadius: 0, pointHoverRadius: 4, pointHoverBackgroundColor: '#fff', pointHoverBorderWidth: 2, cubicInterpolationMode: 'monotone', tension: 0.2, borderWidth: 1, }; const { PRIMARY_TEXT, SECONDARY_TEXT, fillBelowColor, fillBelowLineColor } = this.CSS_COLOR; const fillAbove = this.fillColors.fillAbove; const BORDER_COLORS = { BOLD: isBackground ? CHART_COLOR.MOON_LINE_BOLD : PRIMARY_TEXT, LIGHT: isBackground ? fillBelowLineColor : SECONDARY_TEXT, }; const chartData = this.todayData.chartData; const labels = chartData.map((data) => formatTime(new Date(data.timeLabel), this._locale)); const moonData = chartData.map((data) => data.moon.altitude); const moonDataset = { label: 'Moon', data: moonData, fill: { target: { value: 0 }, // Fill area above 0° altitude above: fillAbove, below: fillBelowColor, }, segment: { borderColor: (ctx: ScriptableLineSegmentContext) => ctx.p0.parsed.y >= -0.001 && ctx.p1.parsed.y >= -0.001 ? BORDER_COLORS.BOLD : BORDER_COLORS.LIGHT, borderWidth: (ctx: ScriptableLineSegmentContext) => ctx.p0.parsed.y >= -0.001 && ctx.p1.parsed.y >= -0.001 ? CHART_DATA.BORDER_WIDTH_BOLD : CHART_DATA.BORDER_WIDTH_LIGHT, }, yAxisID: 'y', ...SHARED_OPTIONS, }; return { labels, datasets: [moonDataset], }; } private _getChartOptions(): ChartOptions { const elevationLabel = this.card.localize('card.altitude'); const azimuthLabel = this.card.localize('card.azimuth'); const { DEFAULT_PRIMARY_COLOR } = this.CSS_COLOR; const formatedTitle = (time: number) => { const dateStr = formatDateShort(new Date(time), this._locale); return `${dateStr}`; }; const chartData = this.todayData.chartData; const direction = chartData.map((data) => data.moon.azimuth); const values = [...Object.values(chartData).map((data) => data.moon.altitude)]; const minMax = { suggestedMin: Math.round(Math.min(...values) - 30), suggestedMax: Math.round(Math.max(...values) + 30), }; const SHARED_TICKS_Y = { ...minMax, ticks: { display: false, }, border: { display: false }, grid: { display: false }, padding: 0, z: -10, }; const scales: ChartOptions['scales'] = {}; scales['x'] = { grid: { display: false }, ticks: { display: false, }, border: { display: false }, }; scales['y'] = { ...SHARED_TICKS_Y, position: 'left', }; const layout: ChartOptions['layout'] = { autoPadding: false, padding: { left: -8, right: -8, }, }; const plugins: ChartOptions['plugins'] = {}; plugins['legend'] = { labels: { usePointStyle: false, boxWidth: 0, boxHeight: 0, padding: 20, color: CHART_COLOR.SECONDARY_TEXT, textAlign: 'left', font: { size: 14, }, }, position: 'bottom', align: 'end', display: false, }; plugins['tooltip'] = { titleColor: DEFAULT_PRIMARY_COLOR, displayColors: false, // padding: 10, callbacks: { title: function (tooltipItem) { const time = chartData[tooltipItem[0].dataIndex].timeLabel; const formatedDate = formatedTitle(time); const formatedTime = tooltipItem[0].label; return `${formatedDate} - ${formatedTime}`; }, label: function (tooltipItem) { const itemIndex = tooltipItem.parsed.x; const directionValue = direction[itemIndex]; const value = Math.round(tooltipItem.parsed.y); const azimuth = `${azimuthLabel}: ${directionValue}`; const elevation = `${elevationLabel}: ${value}°`; const body = [elevation, azimuth]; return body; }, }, }; const options = {} as ChartOptions; options.interaction = { intersect: false, mode: 'index', axis: 'x', }; options.responsive = true; options.maintainAspectRatio = false; options.resizeDelay = 100; // options.devicePixelRatio = 2; options.layout = layout; options.scales = scales; options.plugins = plugins; options.events = ['mousemove', 'mouseout', 'touchstart', 'touchmove', 'click']; return options; } private _nowPosition(): Plugin { const chartData = this.todayData.chartData; const emoji = this.todayData.moonIllumination.phase.emoji; const emojiFontSize = '14px Arial'; const timeLabels = chartData.map((data) => data.timeLabel); const now = this._date; const closestTime = timeLabels.reduce((a, b) => Math.abs(b - now.getTime()) < Math.abs(a - now.getTime()) ? b : a ); const index = timeLabels.indexOf(closestTime); return { id: 'nowLine', beforeDatasetDraw: (chart: Chart) => { const now = this._date; const closestTime = timeLabels.reduce((a, b) => Math.abs(b - now.getTime()) < Math.abs(a - now.getTime()) ? b : a ); const index = timeLabels.indexOf(closestTime); let nowText = `${this.card.localize('card.common.now')} ${formatTime(now, this._locale)} `; // Update the text with current time const { ctx, chartArea: { bottom }, } = chart; const xLabel = chart.scales.x.getPixelForValue(index); const yLabel = chart.scales.y.getPixelForValue(chartData[index].moon.altitude); // const lineColor = hexToRgba(CHART_COLOR.STROKE_LINE, 0.7); ctx.font = '12px Arial'; const width = ctx.measureText(nowText).width; // Draw the dashed line and label for the current time ctx.save(); ctx.beginPath(); ctx.strokeStyle = CHART_COLOR.STROKE_LINE; ctx.setLineDash([2, 4]); ctx.lineWidth = 1; ctx.moveTo(xLabel, yLabel); ctx.lineTo(xLabel, bottom - 25); ctx.stroke(); // Draw the time label below the line ctx.font = '12px Arial'; ctx.fillStyle = CHART_COLOR.PRIMARY_TEXT; ctx.fillText(nowText, xLabel - width / 2, bottom - 5); ctx.restore(); }, afterDatasetDraw: (chart: Chart) => { const dataSet = chart.getDatasetMeta(0); const { ctx, scales: { x, y }, } = chart; if (!dataSet.hidden) { ctx.font = emojiFontSize; const emojiSize = ctx.measureText(emoji); const xPosition = x.getPixelForValue(index) - emojiSize.width / 2; const totalHeight = emojiSize.actualBoundingBoxAscent + emojiSize.actualBoundingBoxDescent; const yPosition = y.getPixelForValue(chartData[index].moon.altitude) + emojiSize.actualBoundingBoxAscent - totalHeight / 2; ctx.save(); ctx.font = emojiFontSize; ctx.fillStyle = CHART_COLOR.SECONDARY_TEXT; ctx.fillText(emoji, xPosition, yPosition); ctx.restore(); } }, }; } private _midnightPosition(): Plugin { const { SECONDARY_TEXT } = this.CSS_COLOR; const { today: todayFill, nextDay: nextDayFill } = this.fillColors; const fontSize = '12px Arial'; const { chartData } = this.todayData; const timeLabels = chartData.map((data) => data.timeLabel); const now = this._date; // Calculate today's and next day's labels and colors const dayOffset = now.getHours() <= CHART_DATA.OFFSET_TIME ? 0 : 1; const todayMidnight = new Date(now); todayMidnight.setHours(0, 0, 0, 0); todayMidnight.setDate(now.getDate() + dayOffset); let todayLabel: Date = new Date(now); if (dayOffset === 0) { todayLabel.setDate(todayLabel.getDate() - 1); } const labels = { today: formatDateShort(todayLabel, this._locale), nextDay: formatDateShort(todayMidnight, this._locale), }; const fillColor = { today: dayOffset === 0 ? nextDayFill : todayFill, nextDay: dayOffset === 0 ? todayFill : nextDayFill, }; const closestTimeIndex = timeLabels.findIndex( (time) => Math.abs(time - todayMidnight.getTime()) === Math.min(...timeLabels.map((t) => Math.abs(t - todayMidnight.getTime()))) ); const drawLabels = (ctx: CanvasRenderingContext2D, x: number, y: number) => { ctx.save(); ctx.font = fontSize; ctx.fillStyle = SECONDARY_TEXT; const todayWidth = ctx.measureText(labels.today).width; const todayXAlign = x - todayWidth - 20; const arrowOffset = 7; const topOffset = 20; ctx.fillText(labels.today, todayXAlign, y + topOffset); ctx.fillText('→', todayXAlign + arrowOffset + todayWidth, y + topOffset); ctx.fillText('←', x + 2, y + topOffset); ctx.fillText(labels.nextDay, x + 20, y + topOffset); ctx.restore(); }; return { id: 'midnightLine', beforeDraw: (chart: Chart) => { const { ctx, chartArea, scales } = chart; // Ensure chartArea and scales are available if (!chartArea || !scales.x || !scales.y) { return; } const { left, right, bottom, top } = chartArea; const midX = scales.x.getPixelForValue(closestTimeIndex); const midY = scales.y.getPixelForValue(0); const gradientHeight = (bottom - top) * 0.5; // Create gradients const createGradient = (startX: number, color: string) => { const gradient = ctx.createLinearGradient(startX, bottom, startX, bottom - gradientHeight); gradient.addColorStop(0, hexToRgba(color, 1)); gradient.addColorStop(1, hexToRgba(color, 0)); return gradient; }; ctx.save(); // Draw left gradient ctx.fillStyle = createGradient(left, fillColor.today); ctx.fillRect(left, bottom - gradientHeight, midX - left, gradientHeight); // Draw right gradient ctx.fillStyle = createGradient(midX, fillColor.nextDay); ctx.fillRect(midX, bottom - gradientHeight, right - midX, gradientHeight); // Draw lines const lineColor = hexToRgba(CHART_COLOR.STROKE_LINE, 0.5); ctx.strokeStyle = lineColor; ctx.lineWidth = 0.5; // Vertical line at midnight ctx.beginPath(); ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.stroke(); // Horizontal midline ctx.beginPath(); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); ctx.restore(); // Draw labels for the markers drawLabels(ctx, midX, top); }, }; } private _getReadAbleColor() { const todayColor = this.fillColors.today; const allColors: string[] = []; for (const color in tinycolor.names) { allColors.push(color); } const readAbleColors = tinycolor.mostReadable(todayColor, allColors); const baseColor = tinycolor(readAbleColors); const lightColor = baseColor.clone().setAlpha(0.2).toString(); return { baseColor: baseColor.setAlpha(0.4).toString(), lightColor }; } private _timesMarkersPlugin(): Plugin { const labelColors = this._getReadAbleColor(); const fontSize = { primary: '0.9rem Arial', secondary: '0.8rem Arial', }; const chartData = this.todayData.chartData; const values = [...Object.values(chartData).map((data) => data.moon.altitude)]; const minValue = Math.min(...values); const isPast = (time: number): boolean => new Date(time) < this._date; const calculateDuration = (time: number): string => { const diff = DateTime.fromMillis(time).diffNow(); return diff.toFormat("h 'hrs,' m 'min'"); }; const drawPoint = ( ctx: CanvasRenderingContext2D, x: number, y: number, color: string, label: string, lineHeight: number, relativeTime: string ) => { ctx.save(); ctx.beginPath(); ctx.fillStyle = color; ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fill(); ctx.restore(); ctx.save(); ctx.strokeStyle = CHART_COLOR.STROKE_LINE; ctx.lineWidth = 1; ctx.moveTo(x, y + 5); ctx.lineTo(x, y + lineHeight); ctx.stroke(); ctx.restore(); const drawText = (text: string, offsetY: number, size: string) => { ctx.font = size; const textWidth = ctx.measureText(text).width; ctx.fillStyle = color; ctx.fillText(text, x - textWidth / 2, y + offsetY + lineHeight); }; drawText(label, 20, fontSize.primary); if (relativeTime) drawText(relativeTime, 35, fontSize.secondary); }; const drawMarkers = (chart: Chart, times: { time: string; index: number; originalTime: number }[]) => { const { ctx, scales: { x, y }, } = chart; times.forEach((time) => { const xPos = x.getPixelForValue(time.index); const yPos = y.getPixelForValue(0); const color = isPast(time.originalTime) ? labelColors.lightColor : labelColors.baseColor; const relativeTime = isPast(time.originalTime) ? '' : calculateDuration(time.originalTime); const lineHeight = (y.getPixelForValue(minValue) - yPos) / 2; drawPoint(ctx, xPos, yPos, color, time.time, lineHeight, relativeTime); }); }; return { id: 'timesMarkers', beforeDatasetDraw: (chart: Chart) => { const moonTimes = this.moon.timeData.moon; drawMarkers(chart, moonTimes); }, }; } private _expandChartArea = (): Plugin => { return { id: 'expandChartArea', afterRender: (chart: Chart) => { chart.chartArea.left = 0; chart.chartArea.right = this.cardWidth; chart.chartArea.bottom = this.cardHeight; chart.chartArea.top = 0; }, afterUpdate: (chart: Chart) => { chart.chartArea.left = 0; chart.chartArea.right = this.cardWidth; chart.chartArea.bottom = this.cardHeight; chart.chartArea.top = 0; }, }; }; async extractColorData(): Promise { const defaultColors = { today: CHART_COLOR.TODAY_FILL, nextDay: CHART_COLOR.NEXTDAY_FILL, fillAbove: CHART_COLOR.FILL_ABOVE, }; const custom_background = this.card.config?.custom_background; if (!custom_background || this.card.config.show_background === false) { return defaultColors; } try { const data = await extract_color(custom_background); return data; } catch (error) { return defaultColors; } } } declare global { interface HTMLElementTagNameMap { 'lunar-horizon-dynamic': LunarHorizonDynamic; } }