import {
LitElement,
html,
css,
} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
import { version } from "./version.js?v=30";
import './state-dropdown.js';
const stl = await import("./styles.js?v=" + version);
const loc = await import("./localize.js?v=" + version);
const styles = stl.styles;
const localize = loc.localize;
const DEFAULT_IMAGE_URL =
"https://github.com/user-attachments/assets/4ef72288-5ee9-4fa6-b2f3-c34c4160cf42";
const DEFAULT_IMAGE_TEXT = "Default Image";
const fireEvent = (node, type, detail, options) => {
options = options || {};
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,
cancelable: Boolean(options.cancelable),
composed: options.composed === undefined ? true : options.composed,
});
event.detail = detail;
node.dispatchEvent(event);
return event;
};
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export class UltraVehicleCardEditor extends localize(LitElement) {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_batteryLevelEntityFilter: { type: String },
_batteryRangeEntityFilter: { type: String },
_fuelLevelEntityFilter: { type: String },
_fuelRangeEntityFilter: { type: String },
_chargingStatusEntityFilter: { type: String },
_locationEntityFilter: { type: String },
_mileageEntityFilter: { type: String },
_iconGridFilter: { type: String },
_selectedIconGridEntities: { type: Array },
_customIcons: { type: Object },
_iconSearchFilter: { type: String },
_currentEditingEntity: { type: String },
_currentEditingIconType: { type: String },
_carStateEntityFilter: { type: String },
_chargeLimitEntityFilter: { type: String },
_showEntityInformation: { type: Boolean },
_iconSize: { type: Number },
_iconGap: { type: Number },
_iconSizes: { type: Object },
_showRowSeparatorDetails: { type: Boolean },
_mainImageHeight: { type: String },
_chargingImageHeight: { type: String },
_image_type: { type: String },
_image_entity: { type: String },
_layoutType: { type: String },
_showEngineAnimation: { type: Boolean },
_showChargingAnimation: { type: Boolean },
_expandedEntities: { type: Object },
};
}
static get styles() {
return [
styles,
css`
.entity-header {
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.entity-header .handle {
cursor: move;
padding-right: 8px;
}
.entity-header .remove-entity {
margin-left: auto;
}
.entity-name {
flex-grow: 1;
margin: 0 8px;
}
.bar-gradient-section {
margin-top: 16px;
}
.switch-wrapper {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.switch-wrapper span {
margin-left: 8px;
}
.bar-gradient-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.gradient-stop {
display: flex;
align-items: center;
gap: 8px;
}
mwc-button {
margin-top: 8px;
}
.gradient-stop {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(16px);
}
.slider.round {
border-radius: 24px;
}
.slider.round:before {
border-radius: 50%;
}
.description {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 4px;
margin-bottom: 8px;
}
.delete-icon {
cursor: pointer;
color: #ffffff;
margin-left: 8px;
}
.reset-all-colors {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
.reset-all-colors span {
margin-right: 8px;
}
.reset-all-colors ha-icon {
color: var(--primary-text-color);
}
mwc-tab-bar {
border-bottom: 1px solid var(--divider-color);
margin-bottom: 16px;
}
.reset-all-colors {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 8px;
}
.reset-all-colors span {
margin-right: 8px;
font-size: 14px;
}
.reset-icon.clickable {
cursor: pointer;
color: var(--primary-text-color);
}
.gradient-preview-container {
margin-bottom: 16px;
}
.gradient-preview {
height: 30px;
border-radius: 5px;
position: relative;
}
.percentage-marker {
position: absolute;
top: 9%;
transform: translateX(-50%);
}
.marker-line {
width: 2px;
height: 25px;
background-color: var(--uvc-card-background);
margin: 0 auto;
}
.marker-label {
font-size: 10px;
color: var(--primary-text-color);
text-align: center;
margin-top: 2px;
}
.editor-row.template-selected {
display: block;
}
.editor-row.template-selected .editor-item {
flex: 1 1 100%;
width: 100%;
}
state-dropdown {
display: block !important;
width: 100%;
margin-top: 8px;
}
ha-select {
width: 100%;
}
.template-input {
margin-top: 8px;
width: 100%;
}
.tab-bar {
display: flex;
margin-bottom: 16px;
justify-content: space-around;
flex-direction: row;
}
.tab {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active {
border-bottom-color: var(--primary-color);
}
.tab ha-icon {
margin-right: 8px;
}
`
];
}
constructor() {
super();
this._initializeProperties();
this._debouncedColorChanged = debounce(
this._applyColorChange.bind(this),
100
);
this._dialogCloseHandler = this._dialogCloseHandler.bind(this);
this._preventDialogClose = this._preventDialogClose.bind(this);
this._defaultColors = {
};
this._userChangedColors = {};
this._themeChangeListener = this._onThemeChange.bind(this);
this._activeTab = "settings";
this._expandedEntities = {};
}
firstUpdated() {
super.firstUpdated();
this._setupDialogCloseHandlers();
}
_setupDialogCloseHandlers() {
const dialog = this.closest('ha-dialog');
if (dialog) {
dialog.addEventListener('close', this._dialogCloseHandler, true);
dialog.addEventListener('iron-overlay-closed', this._dialogCloseHandler, true);
window.addEventListener('dialog-closed', this._preventDialogClose, true);
}
}
_getCustomLabel(entityId, state) {
return this.config.custom_labels?.[entityId]?.[state] || '';
}
_customLabelChanged(e, entityId, state) {
const value = e.target.value;
if (!this.config.custom_labels) {
this.config.custom_labels = {};
}
if (!this.config.custom_labels[entityId]) {
this.config.custom_labels[entityId] = {};
}
this.config.custom_labels[entityId][state] = value;
this._updateConfigAndRequestUpdate('custom_labels', this.config.custom_labels);
}
_removeDialogCloseHandlers() {
const dialog = this.closest('ha-dialog');
if (dialog) {
dialog.removeEventListener('close', this._dialogCloseHandler, true);
dialog.removeEventListener('iron-overlay-closed', this._dialogCloseHandler, true);
}
window.removeEventListener('dialog-closed', this._preventDialogClose, true);
}
_dialogCloseHandler(e) {
e.preventDefault();
e.stopPropagation();
return false;
}
_preventDialogClose(e) {
e.preventDefault();
e.stopPropagation();
if (e.detail && e.detail.dialog) {
e.detail.dialog.open = true;
}
return false;
}
_initializeProperties() {
this._batteryLevelEntityFilter = "";
this._batteryRangeEntityFilter = "";
this._fuelLevelEntityFilter = "";
this._fuelRangeEntityFilter = "";
this._chargingStatusEntityFilter = "";
this._locationEntityFilter = "";
this._mileageEntityFilter = "";
this._iconGridFilter = "";
this._selectedIconGridEntities = [];
this._customIcons = {};
this._iconInteractions = {};
this._iconStyles = {};
this._iconSearchFilter = "";
this._currentEditingEntity = null;
this._currentEditingIconType = null;
this._carStateEntityFilter = "";
this._chargeLimitEntityFilter = "";
this._showEntityInformation = true;
this._iconSize = 24;
this._iconGap = 12;
this._image_urlFilter = "";
this._charging_image_urlFilter = "";
this._iconSizes = {};
this._showRowSeparatorDetails = false;
this._mainImageHeight = "140px";
this._chargingImageHeight = "140px";
this._image_type = "image";
this._image_entity = "";
this._layoutType = "single";
this._showEngineAnimation = false;
this._showChargingAnimation = false;
}
setConfig(config) {
this.config = {
title: "My Vehicle",
image_url: "",
charging_image_url: "",
image_url_type: "image",
charging_image_url_type: "none",
vehicle_type: "EV",
unit_type: "mi",
level_entity: "",
range_entity: "",
charging_status_entity: "",
location_entity: "",
mileage_entity: "",
show_level: true,
show_range: true,
show_location: true,
show_mileage: true,
show_car_state: true,
show_charge_limit: true,
icon_grid_entities: [],
custom_icons: config.custom_icons || {},
icon_interactions: {},
icon_styles: {},
icon_labels: config.icon_labels || {},
hybrid_display_order: "fuel_first",
car_state_entity: "",
charge_limit_entity: "",
icon_size: 24,
icon_gap: 12,
showEntityInformation:
config.showEntityInformation !== undefined
? config.showEntityInformation
: true,
carStateTextColor: config.carStateTextColor || "",
rangeTextColor: config.rangeTextColor || "",
percentageTextColor: config.percentageTextColor || "",
icon_sizes: config.icon_sizes || {},
engine_on_entity: "",
row_separator_color:
config.row_separator_color || this._getDefaultColorAsHex(),
row_separator_height: config.row_separator_height || 1,
row_separators: config.row_separators || {},
iconActiveColor: config.iconActiveColor || "var(--primary-color)",
iconInactiveColor:
config.iconInactiveColor || "var(--primary-text-color)",
useFormattedEntities: config.useFormattedEntities !== undefined
? config.useFormattedEntities
: true, // Default to true
mainImageHeight: config.image_url_type !== "none" ? (config.mainImageHeight || '140px') : '0px',
chargingImageHeight: config.charging_image_url_type !== "none" ? (config.chargingImageHeight || '140px') : '0px',
showTitle: config.showTitle !== false,
layoutType: config.layoutType || "single",
useBarGradient: config.useBarGradient || false,
barGradientStops: config.barGradientStops || [
{ percentage: 0, color: '#ff0000' },
{ percentage: 100, color: '#00ff00' }
],
carStateTextColor: config.carStateTextColor || "",
rangeTextColor: config.rangeTextColor || "",
percentageTextColor: config.percentageTextColor || "",
cardTitleColor: config.cardTitleColor || "",
cardBackgroundColor: config.cardBackgroundColor || "",
barBackgroundColor: config.barBackgroundColor || "",
barBorderColor: config.barBorderColor || "",
barFillColor: config.barFillColor || "",
limitIndicatorColor: config.limitIndicatorColor || "",
infoTextColor: config.infoTextColor || "",
show_engine_animation: config.show_engine_animation !== false,
show_charging_animation: config.show_charging_animation !== false,
show_charging_status: config.show_charging_status !== false, // Default to true
show_engine_on: config.show_engine_on !== false, // Default to true
engine_on_image_url_type: config.engine_on_image_url_type || "none",
...config,
};
this._handleBackwardCompatibility();
this._initializeIconGridEntities();
this.loadResources(this.hass.language);
}
_handleBackwardCompatibility() {
if (this.config.level_entity && !this.config.battery_level_entity) {
this.config.battery_level_entity = this.config.level_entity;
}
if (this.config.range_entity && !this.config.battery_range_entity) {
this.config.battery_range_entity = this.config.range_entity;
}
}
_initializeIconGridEntities() {
this._selectedIconGridEntities = [...this.config.icon_grid_entities];
this._customIcons = { ...this.config.custom_icons };
this._iconInteractions = { ...this.config.icon_interactions };
this._iconStyles = { ...this.config.icon_styles };
this._iconSize = this.config.icon_size || 24;
this._showEntityInformation = this.config.showEntityInformation;
this._iconGap = this.config.icon_gap || 12;
this._image_urlFilter = "";
this._charging_image_urlFilter = "";
this._iconSizes = { ...this.config.icon_sizes };
this._layoutType = this.config.layoutType;
this._showEngineAnimation = this.config.show_engine_animation !== false;
this._showChargingAnimation = this.config.show_charging_animation !== false;
}
static getStubConfig() {
return {
title: "My Vehicle",
image_url: DEFAULT_IMAGE_URL,
charging_image_url: "",
image_url_type: "default",
charging_image_url_type: "none",
vehicle_type: "EV",
unit_type: "mi",
battery_level_entity: "",
battery_range_entity: "",
fuel_level_entity: "",
fuel_range_entity: "",
charging_status_entity: "",
location_entity: "",
mileage_entity: "",
show_battery: true,
show_battery_range: true,
show_fuel: true,
show_fuel_range: true,
show_location: true,
show_mileage: true,
icon_grid_entities: [],
custom_icons: {},
hybrid_display_order: "fuel_first",
car_state_entity: "",
charge_limit_entity: "",
show_car_state: true,
show_charge_limit: true,
cardBackgroundColor: "",
barBackgroundColor: "",
barFillColor: "",
limitIndicatorColor: "",
iconActiveColor: UltraVehicleCardEditor._getComputedColor("--primary-color"),
iconInactiveColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
carStateTextColor: "",
rangeTextColor: "",
cardTitleColor: "",
percentageTextColor: "",
icon_sizes: {},
icon_labels: {},
useFormattedEntities: false,
mainImageHeight: '140px',
chargingImageHeight: '140px',
showTitle: true,
layoutType: "single",
useBarGradient: false,
barGradientStops: [
{ percentage: 0, color: '#ff0000' },
{ percentage: 100, color: '#00ff00' }
],
show_engine_animation: false,
show_charging_animation: false,
};
}
render() {
if (!this.hass) {
return html``;
}
return html`
this._handleTabChange(0)}>
${this.localize("editor.settings")}
this._handleTabChange(1)}>
${this.localize("editor.icon_grid")}
this._handleTabChange(2)}>
${this.localize("editor.customize")}
${this._activeTab === "settings" ? html`
${this._renderBasicConfig()}
${this._renderLayoutChooser()}
${this._renderFormattedEntitiesToggle()}
${this._renderEntityInformation()}
` : ""}
${this._activeTab === "icon-grid" ? html`
${this._renderIconGridConfig()}
` : ""}
${this._activeTab === "customize" ? html`
${this._renderColorPickers()}
${this._renderBarGradientToggle()}
` : ""}
`;
}
_handleTabChange(index) {
const tabIds = ["settings", "icon-grid", "customize"];
this._activeTab = tabIds[index];
this._refreshConfig();
this.requestUpdate();
}
_renderLayoutChooser() {
return html`
e.stopPropagation()}
>
${this.localize("editor.single_column")}
${this.localize("editor.double_column")}
`;
}
_layoutChanged(e) {
const newLayoutType = e.target.value;
this._layoutType = newLayoutType;
this._updateConfig("layoutType", this._layoutType);
// Set default image heights based on layout type
const defaultHeight = newLayoutType === 'double' ? '62px' : '140px';
// Update mainImageHeight
if (newLayoutType === 'double' && this.config.mainImageHeight === '140px') {
this._updateConfig("mainImageHeight", '62px');
} else if (newLayoutType === 'single' && this.config.mainImageHeight === '62px') {
this._updateConfig("mainImageHeight", '140px');
}
// Update chargingImageHeight
if (newLayoutType === 'double' && this.config.chargingImageHeight === '140px') {
this._updateConfig("chargingImageHeight", '62px');
} else if (newLayoutType === 'single' && this.config.chargingImageHeight === '62px') {
this._updateConfig("chargingImageHeight", '140px');
}
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
}
_renderBasicConfig() {
const defaultHeight = this._layoutType === 'double' ? 62 : 140;
return html`
${this.config.vehicle_type === "Hybrid"
? html`
`
: ""}
${this.localize("editor.main_image_section")}
${this._renderImageUploadField(
this.localize("editor.main_image"),
"image_url",
this.localize("editor.enter_image_url")
)}
${this.config.vehicle_type === "Fuel" || this.config.vehicle_type === "Hybrid" ? html`
${this.localize("editor.engine_on_image_section")}
${this._renderImageUploadField(
this.localize("editor.engine_on_image"),
"engine_on_image_url",
this.localize("editor.enter_image_url")
)}
` : ''}
${this.config.vehicle_type === "EV" || this.config.vehicle_type === "Hybrid" ? html`
${this.localize("editor.charging_image_section")}
${this._renderImageUploadField(
this.localize("editor.charging_image"),
"charging_image_url",
this.localize("editor.enter_image_url")
)}
` : ''}
`;
}
_handleImageUrlInput(e, configKey) {
const newValue = e.target.value;
this._updateConfig(configKey, newValue);
this._fireEvent('config-changed', { config: this.config });
}
_renderFormattedEntitiesToggle() {
return html`
`;
}
_renderEntityInformation() {
return html`
${this._showEntityInformation
? html` ${this._renderEntityPickers()} `
: ""}
`;
}
_toggleEntityInformation() {
this._showEntityInformation = !this._showEntityInformation;
this.config = {
...this.config,
showEntityInformation: this._showEntityInformation,
};
this.configChanged(this.config);
this.requestUpdate();
}
_toggleFormattedEntities(e) {
const useFormattedEntities = e.target.checked;
this._updateConfig("useFormattedEntities", useFormattedEntities);
// Force a re-render of the card
this._fireEvent("config-changed", { config: this.config });
}
_renderEntityPickers() {
const { vehicle_type } = this.config;
return html`
${vehicle_type === "EV" || vehicle_type === "Hybrid"
? html`
${this._renderEntityPicker(
"battery_level_entity",
this.localize("editor.battery_level"),
this.localize("editor.battery_level_description")
)}
${this._renderEntityPicker(
"battery_range_entity",
this.localize("editor.battery_range"),
this.localize("editor.battery_range_description")
)}
${this._renderEntityPicker(
"charging_status_entity",
this.localize("editor.charging_status"),
this.localize("editor.charging_status_description")
)}
${this._renderEntityPicker(
"charge_limit_entity",
this.localize("editor.charge_limit"),
this.localize("editor.charge_limit_description")
)}
`
: ""}
${vehicle_type === "Fuel" || vehicle_type === "Hybrid"
? html`
${this._renderEntityPicker(
"fuel_level_entity",
this.localize("editor.fuel_level"),
this.localize("editor.fuel_level_description")
)}
${this._renderEntityPicker(
"fuel_range_entity",
this.localize("editor.fuel_range"),
this.localize("editor.fuel_range_description")
)}
${this._renderEntityPicker(
"engine_on_entity",
this.localize("editor.engine_on"),
this.localize("editor.engine_on_description")
)}
`
: ""}
${this._renderEntityPicker(
"location_entity",
this.localize("editor.location"),
this.localize("editor.location_description")
)}
${this._renderEntityPicker(
"mileage_entity",
this.localize("editor.mileage"),
this.localize("editor.mileage_description")
)}
${this._renderEntityPicker(
"car_state_entity",
this.localize("editor.car_state"),
this.localize("editor.car_state_description")
)}
`;
}
_renderEntityPicker(configValue, labelText, description) {
const toggleName = this._getToggleName(configValue);
return html`
`;
}
_renderIconGridConfig() {
return html`
${this.localize("editor.icon_grid")}
${this._selectedIconGridEntities.map((entityId, index) =>
this._renderSelectedEntity(entityId, index)
)}
`;
}
_renderSelectedEntity(entityId, index) {
if (entityId === "row-separator") {
return this._renderRowSeparatorEditor(index);
}
const isExpanded = this._expandedEntities[entityId] || false;
const isActiveTemplate = this._isTemplateSelected(entityId, 'active');
const isInactiveTemplate = this._isTemplateSelected(entityId, 'inactive');
const sanitizedEntityId = entityId.replace(/\./g, "_");
const entity = this.hass.states[entityId];
const friendlyName = entity.attributes.friendly_name || entityId;
const customIcon = this._customIcons[entityId] || {};
const defaultIcon = entity.attributes.icon;
const activeIcon = customIcon.active || defaultIcon || "mdi:help-circle";
const inactiveIcon =
customIcon.inactive || defaultIcon || "mdi:help-circle";
const interaction = this._iconInteractions[entityId] || { type: "none" };
const buttonStyle = this._iconStyles[entityId] || "icon";
const activeColor =
customIcon.activeColor ||
UltraVehicleCardEditor._getComputedColor("--primary-color");
const inactiveColor =
customIcon.inactiveColor ||
UltraVehicleCardEditor._getComputedColor("--primary-text-color");
return html`
this._onDragStart(e, index)}"
data-entity-id="${entityId}"
>
${this._expandedEntities[entityId] ? html`
${this._renderEntityDetails(entityId)}
` : ''}
`;
}
_renderEntityDetails(entityId) {
const sanitizedEntityId = entityId.replace(/\./g, "_");
const entity = this.hass.states[entityId];
const customIcon = this._customIcons[entityId] || {};
const defaultIcon = entity.attributes.icon;
const activeIcon = customIcon.active || defaultIcon || "mdi:help-circle";
const inactiveIcon = customIcon.inactive || defaultIcon || "mdi:help-circle";
const interaction = this._iconInteractions[entityId] || { type: "none" };
const buttonStyle = this._iconStyles[entityId] || "icon";
const activeColor = customIcon.activeColor || UltraVehicleCardEditor._getComputedColor("--primary-color");
const inactiveColor = customIcon.inactiveColor || UltraVehicleCardEditor._getComputedColor("--primary-text-color");
const isActiveTemplate = this._isTemplateSelected(entityId, 'active');
const isInactiveTemplate = this._isTemplateSelected(entityId, 'inactive');
const iconLabelPosition = (this.config.icon_labels && this.config.icon_labels[entityId]) || "none";
return html`
this._handleIconChange(e, "inactive", entityId)}
>
this._setNoIcon(entityId, "inactive")}
.selected=${inactiveIcon === "no-icon"}
>${inactiveIcon === "no-icon" ? "✓ " : ""}${this.localize("editor.no_icon")}
this._handleTemplateSelected(e, entityId, 'inactive')}
?disableDropdown=${isActiveTemplate}
>
this._handleIconChange(e, "active", entityId)}
>
this._setNoIcon(entityId, "active")}
.selected=${activeIcon === "no-icon"}
>${activeIcon === "no-icon" ? "✓ " : ""}${this.localize("editor.no_icon")}
this._handleTemplateSelected(e, entityId, 'active')}
?disableDropdown=${isInactiveTemplate}
>
${this._renderIconColorPicker(
this.localize("editor.inactive_icon_color"),
entityId,
"inactive",
inactiveColor
)}
${iconLabelPosition !== "none" ? html`
this._customLabelChanged(e, entityId, 'inactive')}"
placeholder="${this.localize("editor.custom_label_placeholder")}"
/>
` : ''}
${this._renderIconColorPicker(
this.localize("editor.active_icon_color"),
entityId,
"active",
activeColor
)}
${iconLabelPosition !== "none" ? html`
this._customLabelChanged(e, entityId, 'active')}"
placeholder="${this.localize("editor.custom_label_placeholder")}"
/>
` : ''}
${this._renderInteractionSelect(entityId, interaction)}
`;
}
_handleTemplateSelected(e, entityId, stateType) {
const otherStateType = stateType === 'active' ? 'inactive' : 'active';
const otherDropdown = this.shadowRoot.querySelector(`state-dropdown[data-entity-id="${entityId}"][data-state-type="${otherStateType}"]`);
if (otherDropdown) {
otherDropdown.disableDropdown = e.detail.selected;
if (e.detail.selected) {
// Reset the other dropdown to 'default' if a template is selected
otherDropdown.value = 'default';
this._updateStateConfig(entityId, otherStateType, 'default');
}
}
// Force a re-render of the entire entity details
this.requestUpdate();
}
_isTemplateSelected(entityId, stateType) {
const config = this.config.custom_icons?.[entityId] || {};
return config[`${stateType}State`]?.startsWith('template:') || false;
}
_updateStateConfig(entityId, stateType, value) {
if (!this.config.custom_icons) {
this.config.custom_icons = {};
}
if (!this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId] = {};
}
this.config.custom_icons[entityId][`${stateType}State`] = value;
this.configChanged(this.config);
}
_toggleRowSeparatorDetails(index) {
const detailsElement = this.shadowRoot.querySelector(
`#row-separator-details-${index}`
);
const toggleIcon = this.shadowRoot.querySelector(
`.selected-entity[data-entity-id="row-separator"]:nth-child(${
index + 1
}) .toggle-details`
);
if (detailsElement && toggleIcon) {
const isHidden =
detailsElement.style.display === "none" ||
!detailsElement.style.display;
detailsElement.style.display = isHidden ? "block" : "none";
toggleIcon.icon = isHidden ? "mdi:chevron-up" : "mdi:chevron-down";
}
}
_updateRowSeparatorConfig(index, property, value) {
if (!this.config.row_separators) {
this.config.row_separators = {};
}
if (!this.config.row_separators[index]) {
this.config.row_separators[index] = {};
}
if (value === '') {
delete this.config.row_separators[index][property];
} else {
this.config.row_separators[index][property] = value;
}
this.configChanged(this.config);
this.requestUpdate();
}
_onDrop(e) {
e.preventDefault();
let fromIndex;
if (e.dataTransfer) {
fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
} else {
fromIndex = this._draggedIndex;
}
const toIndex = [...e.currentTarget.children].indexOf(
e.target.closest(".selected-entity")
);
if (fromIndex !== undefined && fromIndex !== toIndex && toIndex !== -1) {
const newOrder = [...this._selectedIconGridEntities];
const [removed] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, removed);
this._selectedIconGridEntities = newOrder;
this._updateIconGridConfig();
}
// Reset the dragged index
this._draggedIndex = undefined;
}
_handleButtonStyleChange(entityId, style) {
this._iconStyles = {
...this._iconStyles,
[entityId]: style,
};
this._updateIconStylesConfig();
}
_updateIconStylesConfig() {
this.config = {
...this.config,
icon_styles: this._iconStyles,
};
this.configChanged(this.config);
}
_getDefaultColor(colorType) {
const style = getComputedStyle(this);
return colorType === "active"
? style.getPropertyValue("--primary-color").trim()
: style.getPropertyValue("--primary-text-color").trim();
}
_iconColorChanged(e, entityId, iconType) {
const color = e.target.value;
if (!this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId] = {};
}
this.config.custom_icons[entityId][`${iconType}Color`] = color;
this._updateConfigAndRequestUpdate(
"custom_icons",
this.config.custom_icons
);
}
_resetIconColor(e, entityId, iconType) {
e.stopPropagation();
const defaultColor =
iconType === "active"
? UltraVehicleCardEditor._getComputedColor("--primary-color")
: UltraVehicleCardEditor._getComputedColor("--primary-text-color");
if (this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId][`${iconType}Color`] = defaultColor;
}
this._updateConfigAndRequestUpdate(
"custom_icons",
this.config.custom_icons
);
}
_updateCustomIconsConfig() {
const cleanedCustomIcons = Object.entries(this._customIcons).reduce(
(acc, [key, value]) => {
const cleanedValue = {
active: value.active === "" ? undefined : value.active,
inactive: value.inactive === "" ? undefined : value.inactive,
activeColor: value.activeColor,
inactiveColor: value.inactiveColor,
};
if (cleanedValue.active || cleanedValue.inactive) {
acc[key] = cleanedValue;
}
return acc;
},
{}
);
this.config = {
...this.config,
custom_icons: cleanedCustomIcons,
};
this.configChanged(this.config);
}
_toggleEntityDetails(entityId, event) {
// Stop propagation to prevent conflicts with drag events
event.stopPropagation();
this._expandedEntities = {
...this._expandedEntities,
[entityId]: !this._expandedEntities[entityId]
};
this.requestUpdate();
}
_getNavigationPaths() {
return [
"overview",
"map",
"logbook",
"history",
"energy",
"config",
"developer-tools",
"lovelace",
"devices",
"integrations",
"automations",
"scenes",
"scripts",
"areas",
"tags",
"people",
];
}
_updateIndices() {
const elements = this.shadowRoot.querySelectorAll(".selected-entity");
elements.forEach((element, index) => {
element.dataset.index = index;
});
}
_renderInteractionSelect(entityId, interaction) {
const interactions = [
{ value: "more-info", label: this.localize("editor.more_info") },
{ value: "toggle", label: this.localize("editor.toggle") },
{ value: "navigate", label: this.localize("editor.navigate") },
{ value: "url", label: this.localize("editor.url") },
{ value: "trigger", label: this.localize("editor.trigger") },
{ value: "none", label: this.localize("editor.none") },
];
return html`
${this._renderInteractionOptions(entityId, interaction)}
`;
}
_renderInteractionOptions(entityId, interaction) {
switch (interaction.type) {
case "navigate":
return this._renderNavigationOption(entityId, interaction);
case "url":
return this._renderUrlOption(entityId, interaction);
default:
return html``;
}
}
_renderNavigationOption(entityId, interaction) {
const paths = this._getNavigationPaths();
return html`
`;
}
_renderUrlOption(entityId, interaction) {
return html`
this._updateInteractionOption(entityId, "url", e.target.value)}
/>
`;
}
_handleInteractionTypeChange(entityId, newType) {
this._iconInteractions = {
...this._iconInteractions,
[entityId]: { type: newType },
};
this._updateIconInteractionsConfig();
this.requestUpdate();
}
_getDisplayImageUrl(url) {
return url && url.startsWith("data:image")
? this.localize("editor.uploaded_image")
: url;
}
_updateInteractionOption(entityId, option, value) {
this._iconInteractions = {
...this._iconInteractions,
[entityId]: {
...this._iconInteractions[entityId],
[option]: value,
},
};
this._updateIconInteractionsConfig();
}
_iconSizeChanged(e) {
this._iconSize = parseInt(e.target.value);
this.config = {
...this.config,
icon_size: this._iconSize,
};
this.configChanged(this.config);
fireEvent(this, "config-changed", { config: this.config });
}
_onDragStart(e, index) {
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", index.toString());
}
// Store the index in a class property as a fallback
this._draggedIndex = index;
}
_onDragOver(e) {
e.preventDefault();
}
_onDrop(e) {
e.preventDefault();
let fromIndex;
if (e.dataTransfer) {
fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
} else {
fromIndex = this._draggedIndex;
}
const toIndex = [...e.currentTarget.children].indexOf(
e.target.closest(".selected-entity")
);
if (fromIndex !== undefined && fromIndex !== toIndex && toIndex !== -1) {
const newOrder = [...this._selectedIconGridEntities];
const [removed] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, removed);
this._selectedIconGridEntities = newOrder;
this._updateIconGridConfig();
}
// Reset the dragged index
this._draggedIndex = undefined;
}
_handleIconChange(e, iconType, entityId) {
const newIcon = e.detail.value;
if (newIcon === "") {
this._clearIcon(entityId, iconType);
} else {
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[iconType]: newIcon,
},
};
this._updateCustomIconsConfig();
}
}
_updateIconInteractionsConfig() {
const newConfig = {
...this.config,
icon_interactions: this._iconInteractions,
};
this.configChanged(newConfig);
}
_renderColorPickers() {
const getDefaultColor = (property) => {
const style = getComputedStyle(this);
return (
style.getPropertyValue(property).trim() ||
style.getPropertyValue(`--${property}`).trim()
);
};
const defaultColors = {
cardTitleColor: getDefaultColor("--primary-text-color"),
cardBackgroundColor: UltraVehicleCardEditor._getComputedColor("--ha-card-background") || UltraVehicleCardEditor._getComputedColor("--card-background-color"),
barBackgroundColor: getDefaultColor("--secondary-text-color"),
barBorderColor: getDefaultColor("--secondary-text-color"),
barFillColor: getDefaultColor("--primary-color"),
limitIndicatorColor: getDefaultColor("--primary-text-color"),
infoTextColor: getDefaultColor("--secondary-text-color"),
carStateTextColor: getDefaultColor("--primary-text-color"),
rangeTextColor: getDefaultColor("--primary-text-color"),
percentageTextColor: getDefaultColor("--primary-text-color"),
};
return html`
${this.localize("editor.colors")}
${this.localize("editor.custom_colors_description")}
${this.localize("editor.reset_all_colors")}
${Object.entries(defaultColors).map(
([key, defaultValue]) => html`
${this._renderColorPicker(
this.localize(`editor.${key}`),
key,
defaultValue
)}
`
)}
`;
}
_refreshConfig() {
// Refresh the configuration values
this.config = { ...this.config };
this.requestUpdate();
}
_renderBarGradientToggle() {
return html`
${this.localize("editor.bar_gradient_description")}
${this.config.useBarGradient ? this._renderBarGradientOptions() : ''}
`;
}
_renderBarGradientOptions() {
const gradientStops = this.config.barGradientStops || this._getDefaultGradientStops();
return html`
${this._renderGradientPreview(gradientStops)}
${gradientStops.map((stop, index) => html`
this._updateGradientStop(index, 'percentage', parseInt(e.target.value))}
label="${this.localize("editor.percentage")}"
>
this._deleteGradientStop(index)}"
title="${this.localize("editor.delete_gradient_stop")}"
>
`)}
${gradientStops.length < 11 ? html`
${this.localize("editor.add_gradient_stop")}
` : ''}
`;
}
_renderGradientPreview(stops) {
const sortedStops = stops.slice().sort((a, b) => a.percentage - b.percentage);
const gradientString = sortedStops.map(stop => `${stop.color} ${stop.percentage}%`).join(', ');
return html`
${[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map(percentage => html`
`)}
`;
}
_handleUseBarGradientChange(e) {
const useBarGradient = e.target.checked;
if (useBarGradient && (!this.config.barGradientStops || this.config.barGradientStops.length === 0)) {
// Set default gradient stops when first enabled
this._updateConfig('barGradientStops', [
{ percentage: 0, color: '#FF0000' }, // Red at 0%
{ percentage: 100, color: '#00FF00' } // Green at 100%
]);
}
this._updateConfig('useBarGradient', useBarGradient);
}
_updateGradientStop(index, property, value) {
const gradientStops = [...(this.config.barGradientStops || [])];
gradientStops[index] = { ...gradientStops[index], [property]: value };
this._updateConfig('barGradientStops', gradientStops);
}
_resetGradientStopColor(e, index) {
e.stopPropagation();
const defaultColors = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff'];
const gradientStops = [...(this.config.barGradientStops || [])];
gradientStops[index] = { ...gradientStops[index], color: defaultColors[index % defaultColors.length] };
this._updateConfig('barGradientStops', gradientStops);
}
_deleteGradientStop(index) {
let gradientStops = [...(this.config.barGradientStops || this._getDefaultGradientStops())];
if (gradientStops.length > 2) {
gradientStops.splice(index, 1);
} else {
// If we're trying to delete when only 2 stops remain, reset to default
gradientStops = this._getDefaultGradientStops();
}
this._updateConfig('barGradientStops', gradientStops);
}
_getDefaultGradientStops() {
return [
{ percentage: 0, color: '#FF0000' },
{ percentage: 100, color: '#00FF00' }
];
}
_getFullGradientStops() {
return [
{ percentage: 0, color: '#FF0000' },
{ percentage: 10, color: '#FF1A00' },
{ percentage: 20, color: '#FF3300' },
{ percentage: 30, color: '#FF4D00' },
{ percentage: 40, color: '#FF6600' },
{ percentage: 50, color: '#FFFF00' },
{ percentage: 60, color: '#CCFF00' },
{ percentage: 70, color: '#99FF00' },
{ percentage: 80, color: '#66FF00' },
{ percentage: 90, color: '#33FF00' },
{ percentage: 100, color: '#00FF00' }
];
}
_addGradientStop() {
const gradientStops = [...(this.config.barGradientStops || this._getDefaultGradientStops())];
if (gradientStops.length < 11) {
const fullStops = this._getFullGradientStops();
const newStop = fullStops.find(stop => !gradientStops.some(existing => existing.percentage === stop.percentage));
if (newStop) {
gradientStops.push(newStop);
gradientStops.sort((a, b) => a.percentage - b.percentage);
this._updateConfig('barGradientStops', gradientStops);
}
} else {
console.warn("Maximum of 11 gradient stops reached");
}
}
_renderColorPicker(label, configKey, defaultValue) {
const currentValue = this.config[configKey] || defaultValue;
const textColor = this._getContrastYIQ(currentValue);
return html`
`;
}
_colorChanged(e, configKey) {
const color = e.target.value;
this._userChangedColors[configKey] = color !== this._defaultColors[configKey];
this._debouncedColorChanged(configKey, color);
}
_debouncedColorChanged(configKey, color) {
// Clean up and potentially expand the color before applying
const cleanedColor = this._cleanAndExpandColor(color);
this.config = { ...this.config, [configKey]: cleanedColor };
this.configChanged(this.config);
}
_cleanAndExpandColor(color) {
// Remove any non-hex characters
color = color.replace(/[^0-9A-Fa-f#]/g, '');
// Ensure only one '#' at the start
color = color.replace(/#+/g, '#');
if (color.includes('#') && !color.startsWith('#')) {
color = '#' + color.replace('#', '');
}
return color;
}
_expandHexColor(color) {
return '#' + color.slice(1).split('').map(char => char + char).join('');
}
_rgbaToHex(rgba) {
const [r, g, b] = rgba.match(/\d+/g).map(Number);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
static _expandHexColor(color) {
if (color && color.charAt(0) === '#' && color.length === 0) {
return color.replace(/([0-9A-F])/gi, '$1$1');
}
return color;
}
_resetColor(configKey, defaultValue, e) {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation();
}
const expandedDefaultColor = UltraVehicleCardEditor._expandHexColor(defaultValue);
this._userChangedColors[configKey] = false;
this._applyColorChange(configKey, expandedDefaultColor);
this.requestUpdate();
}
_updateIconBackground() {
const cardBackgroundColor = this.config.cardBackgroundColor || getComputedStyle(this).getPropertyValue('--card-background-color').trim();
const isDarkBackground = this._isColorDark(cardBackgroundColor);
this._updateIconBackgroundColor(isDarkBackground);
}
_handleStateConfigChange(e) {
const { config, entityId, stateType, attributeValue } = e.detail;
let newConfig = { ...this.config, custom_icons: {...this.config?.custom_icons, [entityId]: {...this.config?.custom_icons?.[entityId]} }};
newConfig.custom_icons[entityId][`${stateType}State`] = config[`${stateType}State`];
if (config[`${stateType}State`].startsWith('attribute:') && attributeValue) {
newConfig.custom_icons[entityId][`${stateType}State`] += `:${attributeValue}`;
}
this.config = newConfig;
this.configChanged(this.config);
}
_fireEvent(type, detail) {
const event = new CustomEvent(type, {
detail,
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
_toggleChanged(ev) {
const target = ev.target;
if (target.configValue) {
this.config = {
...this.config,
[target.configValue]: target.checked,
};
this.configChanged(this.config);
}
}
_vehicleTypeChanged(ev) {
this.config = {
...this.config,
vehicle_type: ev.target.value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_unitTypeChanged(ev) {
this.config = {
...this.config,
unit_type: ev.target.value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_hybridOrderChanged(ev) {
this.config = {
...this.config,
hybrid_display_order: ev.target.value,
};
this.configChanged(this.config);
}
_renderImageUploadField(label, configKey, placeholder) {
const imageTypeKey = `${configKey}_type`;
const entityKey = configKey === 'image_url' ? 'image_entity' :
configKey === 'charging_image_url' ? 'charging_image_entity' :
'engine_on_image_entity';
const value = this.config[configKey] || "";
const currentType = this.config[imageTypeKey] || "default";
return html`
`;
}
_entityHasImage(state) {
if (typeof state.state === 'string' && state.state.startsWith('http')) {
return true;
}
for (const [key, value] of Object.entries(state.attributes)) {
if (typeof value === 'string' && value.startsWith('http')) {
return true;
}
}
return false;
}
_renderEntityPickerWithoutToggle(configValue, labelText, description) {
return html`
`;
}
_renderImageInput(configKey, type, value, placeholder) {
value = value || DEFAULT_IMAGE_URL;
switch (type) {
case "entity":
return html`
`;
case "template":
return html`
`;
default: // 'image'
return html`
`;
}
}
async _templateChanged(ev, configKey) {
const newValue = ev.target.value;
try {
const result = await this._evaluateTemplate(newValue);
if (result) {
this._updateConfig(configKey, newValue);
}
} catch (error) {
console.error("Error evaluating template:", error);
}
}
_renderTemplatePicker(configKey, value) {
const templates = this._getTemplateHelpers();
return html`
`;
}
_templatePicked(ev) {
const target = ev.target;
const configValue = target.configValue;
const newValue = ev.detail.value || "";
this._updateConfig(configValue, newValue);
}
_updateConfig(key, value) {
if (typeof key === 'object') {
this.config = { ...this.config, ...key };
} else {
this.config = { ...this.config, [key]: value };
}
this.configChanged(this.config);
this.requestUpdate();
}
_getTemplateHelpers() {
return Object.keys(this.hass.states)
.filter(
(entityId) =>
entityId.startsWith("template.") || entityId.startsWith("input_text.")
)
.map((entityId) => ({
value: `{{ states('${entityId}') }}`,
name: this.hass.states[entityId].attributes.friendly_name || entityId,
}));
}
_entityPicked(e, configKey) {
const newValue = e.detail.value;
if (newValue) {
this._updateConfig(configKey, newValue);
}
}
_handleImageUpload(e, configKey) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target.result;
this._updateConfig(configKey, imageData);
this._updateConfig(`${configKey}_type`, 'image');
this.requestUpdate();
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
};
reader.readAsDataURL(file);
}
}
_selectEntity(configValue, entityId) {
const entity = this.hass.states[entityId];
let imageUrl = entity.state;
if (!imageUrl.startsWith('http')) {
imageUrl = Object.values(entity.attributes).find(attr => typeof attr === 'string' && attr.startsWith('http')) || '';
}
this.config = {
...this.config,
[configValue]: entityId,
[`${configValue.replace('_entity', '_url')}`]: imageUrl,
};
this[`_${configValue}Filter`] = "";
this.configChanged(this.config);
}
_iconGridFilterChanged(e) {
this._iconGridFilter = e.target.value;
this.requestUpdate();
}
_addIconGridEntity(entityId) {
if (this._selectedIconGridEntities.length === 0) {
this._addRowSeparator();
}
this._selectedIconGridEntities.push(entityId);
this._updateIconGridConfig();
this._iconGridFilter = "";
}
_removeIconGridEntity(index) {
const removedEntityId = this._selectedIconGridEntities[index];
this._selectedIconGridEntities = this._selectedIconGridEntities.filter(
(_, i) => i !== index
);
if (removedEntityId === "row-separator") {
// Remove the row separator configuration
const { [index]: _, ...restSeparators } = this.config.row_separators;
this.config.row_separators = restSeparators;
} else {
const { [removedEntityId]: _, ...restIcons } = this._customIcons;
this._customIcons = restIcons;
}
// If all entities are removed, remove the last row separator
if (
this._selectedIconGridEntities.length === 1 &&
this._selectedIconGridEntities[0] === "row-separator"
) {
this._selectedIconGridEntities = [];
this.config.row_separators = {};
}
this._updateIconGridConfig();
this._updateCustomIconsConfig();
}
_updateIconGridConfig() {
this._ensureRowSeparatorAtTop();
this.config = {
...this.config,
icon_grid_entities: this._selectedIconGridEntities,
row_separators: { ...this.config.row_separators },
};
this.configChanged(this.config);
}
_ensureRowSeparatorAtTop() {
if (
this._selectedIconGridEntities.length > 0 &&
this._selectedIconGridEntities[0] !== "row-separator"
) {
this._selectedIconGridEntities.unshift("row-separator");
if (!this.config.row_separators) {
this.config.row_separators = {};
}
this.config.row_separators[0] = {
color: "var(--uvc-info-text-color)",
height: 1,
icon_gap: 20,
horizontalAlignment: "center",
verticalAlignment: "middle",
};
this._updateIconGridConfig();
}
}
_getToggleName(configValue) {
switch (configValue) {
case "battery_level_entity":
return this.config.vehicle_type === "EV"
? "show_battery"
: "show_battery";
case "battery_range_entity":
return this.config.vehicle_type === "EV"
? "show_battery_range"
: "show_battery_range";
case "fuel_level_entity":
return "show_fuel";
case "fuel_range_entity":
return "show_fuel_range";
case "location_entity":
return "show_location";
case "mileage_entity":
return "show_mileage";
case "car_state_entity":
return "show_car_state";
case "charge_limit_entity":
return "show_charge_limit";
case "charging_status_entity":
return "show_charging_status";
case "engine_on_entity":
return "show_engine_on";
default:
return `show_${configValue.split("_")[0]}`;
}
}
_formatLabel(key) {
return key
.split(/(?=[A-Z])/)
.join(" ")
.replace(/^\w/, (c) => c.toUpperCase());
}
_getContrastYIQ(color) {
let r, g, b, a = 1;
if (color.startsWith('rgba')) {
[r, g, b, a] = color.match(/[\d.]+/g).map(Number);
} else if (color.startsWith('rgb')) {
[r, g, b] = color.match(/\d+/g).map(Number);
} else if (color.startsWith('#')) {
const hex = color.replace('#', '');
r = parseInt(hex.substr(0, 2), 16);
g = parseInt(hex.substr(2, 2), 16);
b = parseInt(hex.substr(4, 2), 16);
} else {
return '#808080'; // Default to black text if color format is unknown
}
// Adjust for transparency by blending with a white background
r = Math.round(r * a + 255 * (1 - a));
g = Math.round(g * a + 255 * (1 - a));
b = Math.round(b * a + 255 * (1 - a));
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return (yiq >= 128) ? 'black' : 'white';
}
configChanged(newConfig) {
const event = new CustomEvent("config-changed", {
detail: { config: newConfig },
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
async _evaluateTemplate(template) {
try {
if (!this.hass) {
console.error("Home Assistant instance not available");
return null;
}
// Use Home Assistant's template rendering system
return this.hass.callWS({
type: "render_template",
template: template,
entity_ids: [],
});
} catch (error) {
console.error("Error evaluating template:", error);
return null;
}
}
_getIconSize(entityId) {
return this._iconSizes[entityId] || this.config.icon_size || 24;
}
_iconSizeChanged(e, entityId) {
const newSize = parseInt(e.target.value);
this._iconSizes = {
...this._iconSizes,
[entityId]: newSize,
};
this._updateIconSizesConfig();
}
_updateIconLabel(entityId, value) {
if (!this.config.icon_labels) {
this.config.icon_labels = {};
}
this.config.icon_labels[entityId] = value;
this.configChanged(this.config);
}
_addRowSeparator() {
const newIndex = this._selectedIconGridEntities.length;
this._selectedIconGridEntities.push("row-separator");
if (
!this.config.row_separators ||
Object.isFrozen(this.config.row_separators)
) {
this.config.row_separators = { ...this.config.row_separators };
}
this.config.row_separators[newIndex] = {
color: "transparent",
height: 1,
icon_gap: 20,
horizontalAlignment: "center",
verticalAlignment: "middle",
};
this._updateIconGridConfig();
}
_renderRowSeparatorEditor(index) {
return html`
this._onDragStart(e, index)}"
data-entity-id="row-separator"
>
${this._renderRowSeparatorDetails(index)}
`;
}
_renderRowSeparatorDetails(index) {
const separatorConfig = this.config.row_separators?.[index] || {};
return html`
${this._renderRowSeparatorColorPicker(index)}
`;
}
_renderRowSeparatorColorPicker(index) {
const currentColor =
this.config.row_separators?.[index]?.color ||
this._getDefaultColorAsHex();
const textColor = this._getContrastYIQ(currentColor);
const isTransparent = currentColor === "transparent";
return html`
`;
}
_toggleTransparentSeparator(index) {
const currentColor = this.config.row_separators?.[index]?.color;
const defaultColor = this._getDefaultColorAsHex();
const newColor =
currentColor === "transparent" ? defaultColor : "transparent";
this._updateRowSeparatorConfig(index, "color", newColor);
this.requestUpdate();
}
_resetRowSeparatorColor(e, index) {
e.stopPropagation();
const defaultColor = this._getDefaultColorAsHex();
this._updateRowSeparatorConfig(index, "color", defaultColor);
}
_toggleRowSeparatorDetails(index) {
const detailsElement = this.shadowRoot.querySelector(
`#row-separator-details-${index}`
);
const toggleIcon = this.shadowRoot.querySelector(
`.selected-entity[data-entity-id="row-separator"]:nth-child(${
index + 1
}) .toggle-details`
);
if (detailsElement && toggleIcon) {
const isHidden =
detailsElement.style.display === "none" ||
!detailsElement.style.display;
detailsElement.style.display = isHidden ? "block" : "none";
toggleIcon.icon = isHidden ? "mdi:chevron-up" : "mdi:chevron-down";
}
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.setDefaultValues();
this.loadResources(this.config.language || navigator.language).then(() => {
this.requestUpdate();
});
}
_camelToKebab(string) {
return string
.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2")
.toLowerCase();
}
_updateConfigAndRequestUpdate(key, value) {
this.config = {
...this.config,
[key]: value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_rowSeparatorColorChanged(e, index) {
const color = e.target.value;
this._updateRowSeparatorConfig(index, "color", color);
}
_applyColorChange(configKey, color) {
const expandedColor = UltraVehicleCardEditor._expandHexColor(color);
if (configKey === 'cardTitleColor') {
this.config = {
...this.config,
[configKey]: color,
};
this._updateCardTitleColor(color);
} else {
if (configKey.includes("_")) {
// This is an icon-specific color
const [entityId, colorType] = configKey.split("_");
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[colorType]: color,
},
};
this._updateCustomIconsConfig();
} else {
// This is a global color
this.config = {
...this.config,
[configKey]: color,
};
this._updateSingleColor(configKey, color);
}
}
this.requestUpdate();
}
_getIconColor(entityId, colorType) {
const customIcon = this._customIcons[entityId];
if (customIcon && customIcon[`${colorType}Color`]) {
return customIcon[`${colorType}Color`];
}
if (colorType === "active") {
return UltraVehicleCardEditor._getComputedColor("--primary-color");
}
return UltraVehicleCardEditor._getComputedColor("--primary-text-color");
}
// Add this method to hide/show image height inputs
_updateImageHeightVisibility() {
const mainImageHeightInput = this.shadowRoot.querySelector('#main-image-height');
const chargingImageHeightInput = this.shadowRoot.querySelector('#charging-image-height');
const engineOnImageHeightInput = this.shadowRoot.querySelector('#engine-on-image-height');
if (mainImageHeightInput) {
mainImageHeightInput.style.display = this.config.image_url_type === 'none' ? 'none' : 'block';
}
if (chargingImageHeightInput) {
chargingImageHeightInput.style.display = this.config.charging_image_url_type === 'none' ? 'none' : 'block';
}
if (engineOnImageHeightInput) {
engineOnImageHeightInput.style.display = this.config.engine_on_image_url_type === 'none' ? 'none' : 'block';
}
}
// Call this method in the updated lifecycle method
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('config')) {
this._updateImageHeightVisibility();
}
}
// Update the image type change handlers
_onMainImageTypeChange(e) {
this._handleImageSourceChange('image_url', e.target.value);
}
_onChargingImageTypeChange(e) {
this._handleImageSourceChange('charging_image_url', e.target.value);
}
_onEngineOnImageTypeChange(e) {
this._handleImageSourceChange('engine_on_image_url', e.target.value);
}
_handleImageSourceChange(configKey, newType) {
this._updateConfig(`${configKey}_type`, newType);
if (newType === 'none') {
this._updateConfig(configKey, '');
this._updateConfig(`${configKey.replace('_url', '_entity')}`, '');
} else if (newType === 'entity') {
this._updateConfig(configKey, '');
} else if (newType === 'image') {
this._updateConfig(`${configKey.replace('_url', '_entity')}`, '');
if (this.config[configKey] === DEFAULT_IMAGE_URL) {
this._updateConfig(configKey, '');
}
}
this._updateImageHeightVisibility();
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
}
_valueChanged(ev) {
if (!this.config) {
return;
}
const target = ev.target;
const value = target.value;
const configValue = target.configValue;
if (configValue) {
if (configValue === 'show_engine_animation') {
this._showEngineAnimation = target.checked;
this._updateConfig(configValue, this._showEngineAnimation);
} else if (configValue === 'show_charging_animation') {
this._showChargingAnimation = target.checked;
this._updateConfig(configValue, this._showChargingAnimation);
} else if (configValue === 'mainImageHeight' || configValue === 'chargingImageHeight' || configValue === 'engineOnImageHeight') {
// For image height inputs, append 'px' to the value if it's not already there
const newValue = value.endsWith('px') ? value : `${value}px`;
this._updateConfig(configValue, newValue);
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
} else if (configValue === 'image_url' || configValue === 'charging_image_url' || configValue === 'engine_on_image_url') {
this._updateConfig(configValue, value);
} else {
this._updateConfig(configValue, target.checked !== undefined ? target.checked : value);
}
}
}
_entityFilterChanged(e, configKey) {
this[`_${configKey}Filter`] = e.target.value;
this.requestUpdate();
}
_isColorDark(color) {
const rgb = this._hexToRgb(color);
if (!rgb) return false;
const [r, g, b] = rgb.split(',').map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
}
_hexToRgb(hex) {
if (!hex) return null;
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : null;
}
_updateIconBackgroundColor(isDarkBackground) {
const iconBackgroundColor = isDarkBackground ? '#ffffff' : '#000000';
this.style.setProperty('--uvc-icon-background', iconBackgroundColor);
}
firstUpdated() {
super.firstUpdated();
this.addEventListener('click', this._handleEditorClick);
this._addDialogClosePrevention();
}
_addDialogClosePrevention() {
window.addEventListener('dialog-closed', this._preventDialogClose, true);
}
_removeDialogClosePrevention() {
window.removeEventListener('dialog-closed', this._preventDialogClose, true);
}
_preventDialogClose(e) {
if (e.target.tagName === 'HA-DIALOG') {
e.preventDefault();
e.stopPropagation();
}
}
_handleEditorClick(e) {
e.stopPropagation();
}
_handleStateConfigChange(e) {
const { config, entityId, stateType, attributeValue } = e.detail;
let newConfig = { ...this.config, custom_icons: {...this.config?.custom_icons, [entityId]: {...this.config?.custom_icons?.[entityId]} }};
newConfig.custom_icons[entityId][`${stateType}State`] = config[`${stateType}State`];
if (config[`${stateType}State`].startsWith('attribute:') && attributeValue) {
newConfig.custom_icons[entityId][`${stateType}State`] += `:${attributeValue}`;
}
this.config = newConfig;
this.configChanged(this.config);
}
_titleChanged(ev) {
const newTitle = ev.target.value;
this._updateConfig("title", newTitle);
}
_showTitleToggleChanged(ev) {
const showTitle = ev.target.checked;
this._updateConfig("showTitle", showTitle);
}
_updateConfig(key, value) {
if (typeof key === 'object') {
this.config = { ...this.config, ...key };
} else {
this.config = { ...this.config, [key]: value };
}
this.configChanged(this.config);
this.requestUpdate();
}
_toggleFormattedEntities(e) {
const useFormattedEntities = e.target.checked;
this._updateConfig("useFormattedEntities", useFormattedEntities);
this._fireEvent("config-changed", { config: this.config });
}
_updateIconSizesConfig() {
this.config = {
...this.config,
icon_sizes: this._iconSizes,
};
this.configChanged(this.config);
}
_setNoIcon(entityId, iconType) {
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[iconType]: "no-icon",
},
};
this._updateCustomIconsConfig();
this.requestUpdate();
}
_clearIcon(entityId, iconType) {
if (this._customIcons[entityId]) {
const { [iconType]: _, ...rest } = this._customIcons[entityId];
if (Object.keys(rest).length === 0) {
const { [entityId]: __, ...restIcons } = this._customIcons;
this._customIcons = restIcons;
} else {
this._customIcons = {
...this._customIcons,
[entityId]: rest,
};
}
this._updateCustomIconsConfig();
}
}
_getDefaultColorAsHex() {
const defaultColor = getComputedStyle(document.documentElement)
.getPropertyValue("--uvc-info-text-color")
.trim();
if (defaultColor.startsWith("#")) {
return defaultColor;
} else if (defaultColor.startsWith("rgb")) {
const rgb = defaultColor.match(/\d+/g);
return `#${parseInt(rgb[0]).toString(16).padStart(2, "0")}${parseInt(
rgb[1]
)
.toString(16)
.padStart(2, "0")}${parseInt(rgb[2]).toString(16).padStart(2, "0")}`;
}
return "Default"; // Fallback color if unable to determine
}
setDefaultValues() {
if (!this.config.image) {
this._updateConfig("image", DEFAULT_IMAGE_URL);
}
if (!this.config.charging_image) {
this._updateConfig("charging_image", DEFAULT_IMAGE_URL);
}
}
_updateSingleColor(configKey, color) {
const event = new CustomEvent("config-changed", {
detail: { config: { ...this.config, [configKey]: color } },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}
static _getComputedColor(variable) {
const style = getComputedStyle(document.documentElement);
let value = style.getPropertyValue(variable).trim();
if (value.startsWith("#")) {
return this._expandHexColor(value);
} else if (value.startsWith("rgb")) {
// Handle both rgb and rgba
const parts = value.match(/[\d.]+/g);
if (parts.length >= 3) {
const r = parseInt(parts[0]);
const g = parseInt(parts[1]);
const b = parseInt(parts[2]);
const a = parts.length === 4 ? parseFloat(parts[3]) : 1;
if (a < 1) {
// Return rgba for transparent colors
return `rgba(${r}, ${g}, ${b}, ${a})`;
} else {
// Convert to hex for opaque colors
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
}
}
}
// Return the original value if it's not a recognized format
return value;
}
_renderIconColorPicker(label, entityId, iconType) {
const isActive = iconType === "active";
const defaultColor = isActive
? UltraVehicleCardEditor._getComputedColor("--primary-color")
: UltraVehicleCardEditor._getComputedColor("--primary-text-color");
const currentColor = UltraVehicleCardEditor._expandHexColor(
this.config.custom_icons[entityId]?.[`${iconType}Color`] || defaultColor
);
return html`
`;
}
// Update the image type change handlers
_onMainImageTypeChange(e) {
this._handleImageSourceChange('image_url', e.target.value);
}
_onChargingImageTypeChange(e) {
this._handleImageSourceChange('charging_image_url', e.target.value);
}
_onEngineOnImageTypeChange(e) {
this._handleImageSourceChange('engine_on_image_url', e.target.value);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this._handleEditorClick);
this._removeDialogClosePrevention();
window.removeEventListener('set-theme', this._themeChangeListener);
}
_onThemeChange() {
Object.keys(this._defaultColors).forEach((key) => {
if (!this._userChangedColors[key]) {
this._updateConfig(key, this._defaultColors[key]);
}
});
this.requestUpdate();
}
_resetAllColors() {
const defaultColors = {
cardTitleColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
cardBackgroundColor: UltraVehicleCardEditor._getComputedColor("--ha-card-background") || UltraVehicleCardEditor._getComputedColor("--card-background-color"),
barBackgroundColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
barBorderColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
barFillColor: UltraVehicleCardEditor._getComputedColor("--primary-color"),
limitIndicatorColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
infoTextColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
carStateTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
rangeTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
percentageTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
};
Object.entries(defaultColors).forEach(([key, defaultValue]) => {
this._resetColor(key, defaultValue);
});
this.requestUpdate();
}
_resetAllIconColors() {
// Reset all icon colors to default
this._customIcons = Object.keys(this._customIcons).reduce((acc, entityId) => {
acc[entityId] = {
...this._customIcons[entityId],
activeColor: undefined,
inactiveColor: undefined
};
return acc;
}, {});
// Update the config
this._updateCustomIconsConfig();
// Remove the custom CSS properties
this.style.removeProperty('--uvc-icon-active');
this.style.removeProperty('--uvc-icon-inactive');
// Update the config to remove global icon colors
this.config = {
...this.config,
iconActiveColor: undefined,
iconInactiveColor: undefined
};
// Force a re-render of the card
this._fireEvent('config-changed', { config: this.config });
this.requestUpdate();
}
}
customElements.define("ultra-vehicle-card-editor", UltraVehicleCardEditor);