/**
* @module components/DxfExport.js
* @name DxfExport
* @copyright 2024 3Liz
* @license MPL-2.0
*/
import { mainLizmap } from '../modules/Globals.js';
import { html, render } from 'lit-html';
import { transformExtent } from 'ol/proj.js';
/**
* @class
* @name DxfExport
* @augments HTMLElement
*/
export default class DxfExport extends HTMLElement {
// Constants for scale calculations
static STANDARD_DPI = 96;
static INCHES_PER_METER = 39.3701; // More precise value
static METERS_PER_DEGREE_AT_EQUATOR = 111320; // For geographic coordinates
constructor() {
super();
this._exporting = false;
this._scale = 'current';
this._mode = 'SYMBOLLAYERSYMBOLOGY';
this._force2d = false;
this._useTitleAsLayername = false;
this._useMtext = true;
this._clearHandler = null;
this._dockHandler = null;
}
connectedCallback() {
this._clearHandler = () => document.querySelector('#button-dxfexport')?.click();
document.querySelector('.btn-dxfexport-clear')?.addEventListener('click', this._clearHandler);
this._dockHandler = (e) => {
if (e.id === 'dxfexport') {
render(this._template(), this);
}
};
lizMap.events.on({ minidockopened: this._dockHandler });
render(this._template(), this);
}
disconnectedCallback() {
if (this._clearHandler) {
document.querySelector('.btn-dxfexport-clear')?.removeEventListener('click', this._clearHandler);
}
if (this._dockHandler) {
lizMap.events.off({ minidockopened: this._dockHandler });
}
}
/**
* Generates the HTML template for the DXF export component
* @private
* @returns {Object} The lit-html template
*/
_template() {
const exportableLayers = this._getExportableLayers();
const currentScale = this._getCurrentMapScale();
const availableScales = this._getAvailableScales();
return html`
<div class="dxfexport-container">
<p>${lizDict['dxfexport.description'] || 'Export the current map view as a DXF file.'}</p>
<div class="dxfexport-options">
${this._renderScaleSelector(currentScale, availableScales)}
${this._renderModeSelector()}
${this._renderOptionsCheckboxes()}
${this._renderLayersList(exportableLayers)}
</div>
<div class="dxfexport-actions">
<button
id="dxfexport-launch"
class="btn btn-primary ${this._exporting ? 'spinner' : ''}"
?disabled=${this._exporting}
@click=${() => this._launch()}>
${lizDict['dxfexport.launch'] || 'Export to DXF'}
</button>
</div>
</div>
`;
}
/**
* Renders the scale selector
* @private
*/
_renderScaleSelector(currentScale, availableScales) {
return html`
<div class="form-group">
<label for="dxfexport-scale">${lizDict['dxfexport.scale'] || 'Scale'}</label>
<select id="dxfexport-scale" class="form-control"
@change=${(e) => this._handleScaleChange(e)}>
${currentScale ? html`
<option value="current" ?selected=${this._scale === 'current'}>
${lizDict['dxfexport.scale.current'] || 'Current'} (1:${currentScale.toLocaleString()})
</option>
` : ''}
${availableScales.map(scale => html`
<option value=${scale} ?selected=${this._scale === scale}>
1:${scale.toLocaleString()}
</option>
`)}
</select>
</div>
`;
}
/**
* Renders the mode selector
* @private
*/
_renderModeSelector() {
return html`
<div class="form-group">
<label for="dxfexport-mode">${lizDict['dxfexport.mode'] || 'Export mode'}</label>
<select id="dxfexport-mode" class="form-control"
@change=${(e) => { this._mode = e.target.value; }}>
<option value="SYMBOLLAYERSYMBOLOGY" ?selected=${this._mode === 'SYMBOLLAYERSYMBOLOGY'}>
${lizDict['dxfexport.mode.symbollayer'] || 'Symbol layer symbology'}
</option>
<option value="FEATURESYMBOLOGY" ?selected=${this._mode === 'FEATURESYMBOLOGY'}>
${lizDict['dxfexport.mode.feature'] || 'Feature symbology'}
</option>
<option value="NOSYMBOLOGY" ?selected=${this._mode === 'NOSYMBOLOGY'}>
${lizDict['dxfexport.mode.none'] || 'No symbology'}
</option>
</select>
</div>
`;
}
/**
* Renders the options checkboxes
* @private
*/
_renderOptionsCheckboxes() {
return html`
<div class="form-group">
<label class="checkbox">
<input type="checkbox" ?checked=${this._force2d}
@change=${(e) => { this._force2d = e.target.checked; }}>
${lizDict['dxfexport.force2d'] || 'Force 2D (enables line widths)'}
</label>
</div>
<div class="form-group">
<label class="checkbox">
<input type="checkbox" ?checked=${this._useMtext}
@change=${(e) => { this._useMtext = e.target.checked; }}>
${lizDict['dxfexport.use_mtext'] || 'Use MTEXT for labels'}
</label>
</div>
<div class="form-group">
<label class="checkbox">
<input type="checkbox" ?checked=${this._useTitleAsLayername}
@change=${(e) => { this._useTitleAsLayername = e.target.checked; }}>
${lizDict['dxfexport.use_title_as_layername'] || 'Use layer titles as DXF layer names'}
</label>
</div>
`;
}
/**
* Renders the layers list
* @private
*/
_renderLayersList(exportableLayers) {
if (exportableLayers.length === 0) {
return html`
<div class="alert alert-warning" style="font-size: 0.9em; margin-top: 10px;">
<i class="icon-warning-sign"></i>
${lizDict['dxfexport.no_wfs_layers'] || 'No WFS-enabled layers available for export.'}
</div>
`;
}
return html`
<details class="dxfexport-layers" style="margin-top: 10px; border: 1px solid #ddd; border-radius: 4px; padding: 8px;">
<summary style="cursor: pointer; font-weight: bold; user-select: none;">
<i class="icon-list"></i> ${exportableLayers.length}
${lizDict['dxfexport.layers_to_export'] || 'layers will be exported'}
</summary>
<ul style="margin: 8px 0 0 0; padding-left: 20px; font-size: 0.9em; max-height: 200px; overflow-y: auto;">
${exportableLayers.map(layer => html`
<li title="${layer.name}">${layer.title}</li>
`)}
</ul>
</details>
`;
}
/**
* Handles scale selection change
* @private
*/
_handleScaleChange(event) {
const value = event.target.value;
this._scale = value === 'current' ? 'current' : parseInt(value, 10);
render(this._template(), this);
}
/**
* Gets WFS layer names from configuration
* @private
* @returns {Set<string>} Set of WFS layer names
*/
_getWfsLayerNames() {
const wfsFeatureTypes = mainLizmap.initialConfig?.vectorLayerFeatureTypeList || [];
return new Set(wfsFeatureTypes.map(ft => ft.Name));
}
/**
* Gets the list of layers that will be exported
* @private
* @returns {Array<{name: string, title: string}>} Array of exportable layers
*/
_getExportableLayers() {
const layers = [];
const wfsLayerNames = this._getWfsLayerNames();
// Check base layer
const selectedBaseLayer = mainLizmap.state.baseLayers.selectedBaseLayer;
if (selectedBaseLayer?.hasItemState &&
selectedBaseLayer.itemState.wmsName &&
selectedBaseLayer.layerConfig?.typename !== undefined) {
layers.push({
name: selectedBaseLayer.itemState.wmsName,
title: selectedBaseLayer.layerConfig.title || selectedBaseLayer.itemState.wmsName
});
}
// Add visible WFS-enabled layers
mainLizmap.state.rootMapGroup.findExplodedMapLayers()
.filter(layer => layer.visibility)
.forEach(layer => {
const layerName = layer.wmsName || layer.name;
if (wfsLayerNames.has(layerName)) {
layers.push({
name: layerName,
title: layer.layerConfig?.title || layer.name
});
}
});
return layers;
}
/**
* Calculates the current map scale with high precision
* Uses proper geodesic calculations for geographic coordinates
* @private
* @returns {number|null} The current map scale, or null if it cannot be calculated
*/
_getCurrentMapScale() {
try {
const view = mainLizmap.map.getView();
const resolution = view.getResolution();
const units = view.getProjection().getUnits();
if (units === 'm') {
// Projected coordinates - straightforward calculation
return Math.round(resolution * DxfExport.INCHES_PER_METER * DxfExport.STANDARD_DPI);
}
if (units === 'degrees') {
// Geographic coordinates - use precise geodesic calculation
const center = view.getCenter();
const latitude = center[1] * Math.PI / 180; // Convert to radians
// More precise calculation using WGS84 ellipsoid parameters
const metersPerDegree = DxfExport.METERS_PER_DEGREE_AT_EQUATOR * Math.cos(latitude);
const scale = resolution * metersPerDegree * DxfExport.INCHES_PER_METER * DxfExport.STANDARD_DPI;
return Math.round(scale);
}
return null;
} catch (error) {
console.warn('Could not calculate current map scale:', error);
return null;
}
}
/**
* Gets available scales from map configuration
* @private
* @returns {number[]} Array of available scales
*/
_getAvailableScales() {
const scales = mainLizmap.config?.options?.mapScales || [
1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000
];
return Array.from(scales);
}
/**
* Shows a success message
* @private
*/
_showSuccess() {
this._exporting = false;
render(this._template(), this);
document.querySelector('#message .dxfexport-in-progress button')?.click();
mainLizmap._lizmap3.addMessage(
lizDict['dxfexport.success'] || 'DXF export completed successfully.',
'info',
true
).addClass('dxfexport-success');
}
/**
* Shows an error message
* @private
* @param {string} message - Error message
* @param {Error|Object} [error] - Optional error object
*/
_showError(message, error = null) {
let displayMessage = message || lizDict['dxfexport.error'] || 'Error during DXF export';
if (error) {
console.error('DXF export error:', error);
if (error.message) {
displayMessage += `: ${error.message}`;
} else if (error.status) {
const errorMessages = {
404: lizDict['dxfexport.error.notfound'] || 'Export service not found.',
500: lizDict['dxfexport.error.server'] || 'Server error during export.',
503: lizDict['dxfexport.error.unavailable'] || 'Export service temporarily unavailable.'
};
displayMessage = errorMessages[error.status] || `${displayMessage} (HTTP ${error.status})`;
}
}
this._exporting = false;
render(this._template(), this);
mainLizmap._lizmap3.addMessage(displayMessage, 'danger', true).addClass('dxfexport-error');
}
/**
* Builds FORMAT_OPTIONS string for QGIS Server
* @private
* @returns {string} Semicolon-separated FORMAT_OPTIONS
*/
_buildFormatOptions() {
const options = [`MODE:${this._mode}`];
// Add scale
const exportScale = this._scale === 'current' ? this._getCurrentMapScale() : this._scale;
if (exportScale) {
options.push(`SCALE:${exportScale}`);
}
// Add optional parameters
if (this._force2d) options.push('FORCE_2D:TRUE');
if (!this._useMtext) options.push('NO_MTEXT:TRUE');
if (this._useTitleAsLayername) options.push('USE_TITLE_AS_LAYERNAME:TRUE');
return options.join(';');
}
/**
* Collects layer information for export
* @private
* @returns {Object} Object containing layers, styles, opacities, and tokens
*/
_collectLayerInformation() {
const result = {
layers: [],
styles: [],
opacities: [],
filterTokens: [],
selectionTokens: [],
legendOn: [],
legendOff: []
};
const wfsLayerNames = this._getWfsLayerNames();
// Add base layer if compatible
const selectedBaseLayer = mainLizmap.state.baseLayers.selectedBaseLayer;
if (selectedBaseLayer?.hasItemState &&
selectedBaseLayer.itemState.wmsName &&
selectedBaseLayer.layerConfig) {
result.layers.push(selectedBaseLayer.itemState.wmsName);
result.styles.push(selectedBaseLayer.itemState.wmsSelectedStyleName || '');
const baseOpacity = selectedBaseLayer.itemState.opacity || 1;
const configOpacity = selectedBaseLayer.layerConfig.opacity || 1;
result.opacities.push(Math.round(255 * baseOpacity * configOpacity));
}
// Add visible WFS-enabled layers
mainLizmap.state.rootMapGroup.findExplodedMapLayers()
.filter(layer => {
const layerName = layer.wmsName || layer.name;
return layer.visibility && wfsLayerNames.has(layerName);
})
.forEach(layer => {
const params = layer.wmsParameters;
result.layers.push(params.LAYERS);
result.styles.push(params.STYLES);
const opacity = layer.layerConfig?.opacity
? layer.calculateTotalOpacity() * layer.layerConfig.opacity
: layer.calculateTotalOpacity();
result.opacities.push(Math.round(255 * opacity));
// Collect tokens
if (params.FILTERTOKEN) result.filterTokens.push(params.FILTERTOKEN);
if (params.SELECTIONTOKEN) result.selectionTokens.push(params.SELECTIONTOKEN);
if (params.LEGEND_ON) result.legendOn.push(params.LEGEND_ON);
if (params.LEGEND_OFF) result.legendOff.push(params.LEGEND_OFF);
});
return result;
}
/**
* Builds WMS GetMap parameters for DXF export
* @private
* @returns {Object} WMS parameters object
*/
_buildWmsParameters() {
// Get map extent and projection
const mapExtent = mainLizmap.map.getView().calculateExtent(mainLizmap.map.getSize());
const mapProjection = mainLizmap.config.options.projection.ref;
const projectProjection = mainLizmap.config.options.qgisProjectProjection.ref || mapProjection;
// Transform extent if necessary
const extent = mapProjection !== projectProjection
? transformExtent(mapExtent, mapProjection, projectProjection)
: mapExtent;
const size = mainLizmap.map.getSize();
// Build base parameters
const params = {
SERVICE: 'WMS',
REQUEST: 'GetMap',
VERSION: '1.3.0',
FORMAT: 'application/dxf',
TRANSPARENT: true,
CRS: projectProjection,
BBOX: extent.join(','),
WIDTH: size[0],
HEIGHT: size[1],
FORMAT_OPTIONS: this._buildFormatOptions()
};
// Add layer information
const layerInfo = this._collectLayerInformation();
params.LAYERS = layerInfo.layers.join(',');
params.STYLES = layerInfo.styles.join(',');
params.OPACITIES = layerInfo.opacities.join(',');
// Add optional tokens
if (layerInfo.filterTokens.length) params.FILTERTOKEN = layerInfo.filterTokens.join(';');
if (layerInfo.selectionTokens.length) params.SELECTIONTOKEN = layerInfo.selectionTokens.join(';');
if (layerInfo.legendOn.length) params.LEGEND_ON = layerInfo.legendOn.join(';');
if (layerInfo.legendOff.length) params.LEGEND_OFF = layerInfo.legendOff.join(';');
// Generate and add filename
const filename = this._generateFilename();
params.FILE_NAME = filename;
return { params, filename };
}
/**
* Generates a filename for the DXF export
* @private
* @returns {string} Generated filename
*/
_generateFilename() {
let projectName = globalThis['lizUrls']?.params?.project || 'map-export';
projectName = projectName.replace(/^qgis_server_wms_map_[a-z]+_/i, '');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
return `${projectName}_${timestamp}.dxf`;
}
/**
* Downloads a DXF file via XHR
* @private
* @param {string} url - Service URL
* @param {Object} parameters - WMS parameters
* @param {string} filename - Output filename
*/
_downloadDxfFile(url, parameters, filename) {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = () => {
if (xhr.status === 200) {
const type = xhr.getResponseHeader('Content-Type') || 'application/dxf';
const blob = new File([xhr.response], filename, { type });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
a.dispatchEvent(new MouseEvent('click'));
setTimeout(() => URL.revokeObjectURL(downloadUrl), 100);
this._showSuccess();
} else {
this._showError(lizDict['dxfexport.error'], { status: xhr.status });
}
};
xhr.onerror = () => {
this._showError(lizDict['dxfexport.error'], { message: 'Network error' });
};
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send($.param(parameters, true));
}
/**
* Launches the DXF export process
* @private
*/
_launch() {
this._exporting = true;
render(this._template(), this);
try {
// Validate visible layers
const visibleLayers = mainLizmap.state.rootMapGroup.findExplodedMapLayers()
.filter(layer => layer.visibility);
const hasBaseLayer = mainLizmap.state.baseLayers.selectedBaseLayer?.hasItemState;
if (visibleLayers.length === 0 && !hasBaseLayer) {
this._showError(lizDict['dxfexport.no_visible_layers'] || 'No visible layers to export.');
return;
}
// Build parameters and execute export
const { params, filename } = this._buildWmsParameters();
mainLizmap._lizmap3.addMessage(
lizDict['dxfexport.started'] || 'DXF export started...',
'info',
true
).addClass('dxfexport-in-progress');
this._downloadDxfFile(mainLizmap.serviceURL, params, filename);
} catch (error) {
this._showError(lizDict['dxfexport.error'], error);
}
}
}