Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions configure/src/metaconfigs/layer-vector-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,36 @@
"description": "A URL to a custom image (jpeg|jpg|gif|png|svg|webp) for which to use for this legend entry. If the path is relative, it will be relative to the mission's directory. This field will override values in the Shape and Shape From Icon fields.",
"type": "text",
"width": 4
},
{
"field": "propertyName",
"name": "Property Name",
"description": "The name of the property to match for this legend entry.",
"type": "text",
"width": 4
},
{
"field": "propertyValue",
"name": "Property Value",
"description": "The value of the property to match for this legend entry.",
"type": "text",
"width": 4
},
{
"field": "styleMatching",
"name": "Style Matching",
"description": "If true, the feature will use this legend entry for its style. If false, the default style will be applied to the feature.",
"type": "checkbox",
"width": 2,
"defaultChecked": false
},
{
"field": "hideFromLegend",
"name": "Hide From Legend",
"description": "If true, this legend entry will be hidden from the legend.",
"type": "checkbox",
"width": 2,
"defaultChecked": false
}
]
}
Expand Down
30 changes: 30 additions & 0 deletions configure/src/metaconfigs/layer-vectortile-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,36 @@
"description": "A URL to a custom image (jpeg|jpg|gif|png|svg|webp) for which to use for this legend entry. If the path is relative, it will be relative to the mission's directory. This field will override values in the Shape and Shape From Icon fields.",
"type": "text",
"width": 4
},
{
"field": "propertyName",
"name": "Property Name",
"description": "The name of the property to match for this legend entry.",
"type": "text",
"width": 4
},
{
"field": "propertyValue",
"name": "Property Value",
"description": "The value of the property to match for this legend entry.",
"type": "text",
"width": 4
},
{
"field": "styleMatching",
"name": "Style Matching",
"description": "If true, the feature will use this legend entry for its style. If false, the default style will be applied to the feature.",
"type": "checkbox",
"width": 2,
"defaultChecked": false
},
{
"field": "hideFromLegend",
"name": "Hide From Legend",
"description": "If true, this legend entry will be hidden from the legend.",
"type": "checkbox",
"width": 2,
"defaultChecked": false
}
]
}
Expand Down
298 changes: 298 additions & 0 deletions src/essence/Basics/Layers_/LayerConstructors.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,139 @@ import { centroid } from '@turf/turf'

let L = window.L

// Helper function to interpolate between two colors using RGB
function interpolateColor(color1, color2, factor) {
if (!color1 || !color2) return color1 || color2

// Ensure factor is between 0 and 1
factor = Math.max(0, Math.min(1, factor))

// Convert colors to RGB if they're hex
const rgb1 = hexToRgb(color1) || parseRgb(color1) || parseCSSColor(color1)
const rgb2 = hexToRgb(color2) || parseRgb(color2) || parseCSSColor(color2)

if (!rgb1 || !rgb2) return color1 // Fallback if color parsing fails

// Interpolate each RGB component
const r = Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor)
const g = Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor)
const b = Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)

return `rgb(${r}, ${g}, ${b})`
}

// Enhanced function to interpolate between multiple colors using color stops
function interpolateMultipleColors(colorStops, value, minValue, maxValue) {
if (!colorStops || colorStops.length === 0) return null
if (colorStops.length === 1) return colorStops[0].color

// Normalize the value to 0-1 range
const normalizedValue =
maxValue === minValue ? 0 : (value - minValue) / (maxValue - minValue)

// Clamp the normalized value
const clampedValue = Math.max(0, Math.min(1, normalizedValue))

// If we're at the extremes, return the boundary colors
if (clampedValue === 0) return colorStops[0].color
if (clampedValue === 1) return colorStops[colorStops.length - 1].color

// Find the two color stops that bracket our value
for (let i = 0; i < colorStops.length - 1; i++) {
const currentStop = colorStops[i]
const nextStop = colorStops[i + 1]

if (
clampedValue >= currentStop.position &&
clampedValue <= nextStop.position
) {
// Calculate the local factor between these two stops
const stopRange = nextStop.position - currentStop.position
const localFactor =
stopRange === 0
? 0
: (clampedValue - currentStop.position) / stopRange

// Interpolate between the two colors
return interpolateColor(
currentStop.color,
nextStop.color,
localFactor
)
}
}

// Fallback (shouldn't reach here)
return colorStops[colorStops.length - 1].color
}

// Helper function to convert hex color to RGB
function hexToRgb(hex) {
if (!hex || typeof hex !== 'string') return null

// Remove # if present
hex = hex.replace('#', '')

// Handle 3-character hex
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('')
}

if (hex.length !== 6) return null

const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)

return isNaN(r) || isNaN(g) || isNaN(b) ? null : { r, g, b }
}

// Helper function to parse rgb() color strings
function parseRgb(color) {
if (!color || typeof color !== 'string') return null

const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (!match) return null

return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3]),
}
}

// Helper function to parse CSS color strings to RGB using browser's built-in capability
function parseCSSColor(color) {
if (!color || typeof color !== 'string') return null

// Use a temporary element to parse the color
const tempElem = document.createElement('div')
tempElem.style.color = color

// Append to body temporarily to get computed style
document.body.appendChild(tempElem)
const computedColor = window.getComputedStyle(tempElem).color
document.body.removeChild(tempElem)

// If the browser couldn't parse it, computed color will be empty
if (!computedColor || computedColor === '') return null

// Try to parse rgb() or rgba() format
const rgbMatch = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}

return null
}

const tooltipProto = L.Tooltip.prototype
const tooltipProto_setPosition = tooltipProto._setPosition
L.Tooltip.include({
Expand Down Expand Up @@ -94,6 +227,171 @@ export const constructVectorLayer = (
: rad
}

// Check for legend-based property styling (takes priority over configured styles but not over feature.properties.style)
const legendData = L_.layers.data[layerObj.name]?._legend
if (legendData && Array.isArray(legendData)) {
// Group legend entries by property name for gradient interpolation
const propertyGroups = {}
for (let legendEntry of legendData) {
if (
legendEntry.styleMatching &&
legendEntry.propertyName &&
legendEntry.propertyValue !== undefined
) {
if (!propertyGroups[legendEntry.propertyName]) {
propertyGroups[legendEntry.propertyName] = []
}
propertyGroups[legendEntry.propertyName].push(
legendEntry
)
}
}

// Process each property group
for (let propertyName in propertyGroups) {
const featureValue = feature.properties[propertyName]
const entries = propertyGroups[propertyName]

// Check if this should use continuous interpolation
const isNumericValue = typeof featureValue === 'number'

// Only get entries that are marked as continuous
const continuousEntries = entries.filter(
(entry) => entry.shape === 'continuous'
)

const numericEntries = continuousEntries
.map((entry) => ({
...entry,
numericValue: parseFloat(entry.propertyValue),
}))
.filter((entry) => !isNaN(entry.numericValue))
.sort((a, b) => a.numericValue - b.numericValue)

const shouldUseContinuous =
isNumericValue && numericEntries.length >= 2

if (shouldUseContinuous) {
// Use gradient interpolation for continuous numeric values
// Find min and max values for normalization
const minValue = numericEntries[0].numericValue
const maxValue =
numericEntries[numericEntries.length - 1]
.numericValue

// Create color stops for fill colors
const fillColorStops = numericEntries
.filter((entry) => entry.color)
.map((entry) => ({
position:
maxValue === minValue
? 0
: (entry.numericValue - minValue) /
(maxValue - minValue),
color: entry.color,
}))

// Create color stops for stroke colors
const strokeColorStops = numericEntries
.filter((entry) => entry.strokecolor || entry.color)
.map((entry) => ({
position:
maxValue === minValue
? 0
: (entry.numericValue - minValue) /
(maxValue - minValue),
color: entry.strokecolor || entry.color,
}))

// Interpolate colors using the enhanced multi-color function
if (fillColorStops.length >= 1) {
const interpolatedFillColor =
fillColorStops.length === 1
? fillColorStops[0].color
: interpolateMultipleColors(
fillColorStops,
featureValue,
minValue,
maxValue
)

if (interpolatedFillColor) {
fiC = interpolatedFillColor
}
}

if (strokeColorStops.length >= 1) {
const interpolatedStrokeColor =
strokeColorStops.length === 1
? strokeColorStops[0].color
: interpolateMultipleColors(
strokeColorStops,
featureValue,
minValue,
maxValue
)

if (interpolatedStrokeColor) {
col = interpolatedStrokeColor
}
}

break // Found styling, stop processing other properties
} else {
// Use exact matching for discrete values (strings, booleans, or entries not marked as continuous)
let exactMatch = null
// Only check entries that are not marked as continuous
const discreteEntries = entries.filter(
(entry) => entry.shape !== 'continuous'
)

for (let entry of discreteEntries) {
let matches = false
if (
typeof featureValue === 'string' &&
typeof entry.propertyValue === 'string'
) {
matches = featureValue === entry.propertyValue
} else if (
typeof featureValue === 'number' &&
!isNaN(parseFloat(entry.propertyValue))
) {
matches =
featureValue ===
parseFloat(entry.propertyValue)
} else if (typeof featureValue === 'boolean') {
matches =
featureValue ===
(entry.propertyValue === 'true' ||
entry.propertyValue === true)
} else {
matches =
String(featureValue) ===
String(entry.propertyValue)
}

if (matches) {
exactMatch = entry
break
}
}

if (exactMatch) {
if (exactMatch.color) {
fiC = exactMatch.color
}
if (exactMatch.strokecolor) {
col = exactMatch.strokecolor
}
if (exactMatch.color && !exactMatch.strokecolor) {
col = exactMatch.color
}
break // Found styling, stop processing other properties
}
}
}
}

if (feature.properties.hasOwnProperty('style')) {
let className = layerObj.uuid
let layerName = layerObj.style.layerName
Expand Down
5 changes: 5 additions & 0 deletions src/essence/Tools/Legend/LegendTool.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ function drawLegends(tools, _legend, layerUUID, display_name, opacity, shift) {
}

for (let d in _legend) {
// Skip legend entries that should be hidden from the legend
if (_legend[d].hideFromLegend === true) {
continue
}

var shape = _legend[d].shapeImage && _legend[d].shapeImage.trim()
? _legend[d].shapeImage : _legend[d].shapeIcon && _legend[d].shapeIcon.trim()
? _legend[d].shapeIcon : _legend[d].shape
Expand Down