/* eslint-disable prefer-const */ import * as React from "react"; import { FormElementProps, FormElementRegistration } from "@vertigis/workflow"; import IconButton from '@vertigis/web/ui/IconButton'; import { Clear as ClearIcon } from '@mui/icons-material'; import List from '@vertigis/web/ui/List'; import ListItemButton from '@vertigis/web/ui/ListItemButton'; import ListItemIcon from '@vertigis/web/ui/ListItemIcon'; import ListItemText from '@vertigis/web/ui/ListItemText'; import Collapse from '@vertigis/web/ui/Collapse'; import Menu from '@vertigis/web/ui/Menu'; import MenuItem from '@vertigis/web/ui/MenuItem'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import SearchIcon from '@vertigis/web/ui/icons/Search'; import Box from "@vertigis/web/ui/Box" import Checkbox from "@vertigis/web/ui/Checkbox" import { ThemeProvider } from "@emotion/react"; import Typography from '@vertigis/web/ui/Typography'; import { DateRangePicker } from '@mui/x-date-pickers-pro/DateRangePicker'; import dayjs, { Dayjs } from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import customParseFormat from 'dayjs/plugin/customParseFormat' import { DemoContainer } from '@mui/x-date-pickers/internals/demo'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import 'dayjs/locale/en-au'; import Chip from '@mui/material/Chip'; import Button from '@vertigis/web/ui/Button'; import Radio from '@vertigis/web/ui/Radio'; import RadioGroup from '@vertigis/web/ui/RadioGroup'; import FormControlLabel from '@vertigis/web/ui/FormControlLabel'; import AttributeFilterEasyComp from './modules/AttributeFilterEasyComp'; import Stack from '@vertigis/web/ui/Stack'; import Graphic from "esri/Graphic"; const { Search, SearchIconWrapper, StyledInputBase, StyledBadge, LightTooltip, defaultTheme, CustomNumberInput } = AttributeFilterEasyComp; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(customParseFormat); /** * The generic type argument provided to `FormElementProps` controls the type * of `props.value` and `props.setValue(value)`. If your element doesn't need a * `value`, you can omit the type argument. * * You can also declare additional public properties of your element by adding * properties to this interface. The properties will be managed by the Workflow * runtime, and passed in as `props`. You can update the properties by using * `props.setProperty(key, value)`. */ interface AttributeFilterProps extends FormElementProps { features?: Graphic[],//Provide when local query rather web query fields: { name: string; alias: string; type: string; [key: string]: any; }[], dataOld?: Attribute[], //field names & type only at beginning openedAttribute?: { data: Attribute, timestamp: number }, //Data communication purpose newSum?: { sum: number, attri: string, timestamp: number }, //Data communication purpose buttonNaming?: string[], //List of buttons, if name ends with () will show summary number. Except first button, always has () ,and will be apply if this prop not given. If the button is enabled only when there is selection, put <> at end // allFeatures?: object[] //GRAPHICS TO BE HERE footerButton?: boolean,//if true, use the button of default form footer request?: { requestName: string, timestamp: number }, //Following attributes are for keep property in Vertigis only when switching tab searchQuery: string; attributeList?: string[], data: Attribute[]; searchValue: string; summary: number; sqlRule: string; allButtonText: string; rule: string; layerName: string; minValue: number; maxValue: number; startDate: Dayjs; endDate: Dayjs; filteredValues: fieldValue[]; } interface fieldValue { value: string, count: number } interface Attribute { name: string, alias?: string, type?: string, uniqValue?: fieldValue[], display?: boolean, img?: string, visible?: boolean, selection?: string[], timeType?: string, hidden?: boolean, } /** * Provide strictly structured item data to display them and search. * @displayName AttributeFilter * @description For any layer, dynamicly build your SQL query based on all attributes. * @param props Should provide strictly structured Data/openedAttribute */ function AttributeFilter(props: AttributeFilterProps): React.ReactElement { const defaultNumber = 4 const { raiseEvent, searchQuery, data, searchValue, summary, minValue, maxValue, filteredValues, startDate, endDate, sqlRule, allButtonText, rule, layerName, setProperty } = props; const defaultStart = dayjs('2020-01-01') const defaultEnd = dayjs('2050-01-01') const prevSearchValue = React.useRef('') const openAttriName = React.useRef('') const tickedBeforeNewQuery = React.useRef(undefined) const openAttriValues = React.useRef(null); const openAttriTime = React.useRef(null); const requestTime = React.useRef(null); const numberRecord = React.useRef>({}); const tempSummary = React.useRef(0) const prevSum = React.useRef<{ sum: number, attri: string, timestamp: number } | undefined>(undefined) const tempPreviousSummary = React.useRef(0) const minDate = React.useRef(946700187) const maxDate = React.useRef(2209004187) //const [filteredValues, setFilteredValues] = React.useState([]); // New state for filtered data const [showAll, setShowAll] = React.useState(false); const [sortModes, setSortModes] = React.useState>({}); const [sortAnchor, setSortAnchor] = React.useState(null); // const [minValue, setMinValue] = React.useState(-Infinity) // const [maxValue, setMaxValue] = React.useState(Infinity) // const [startDate, setStartDate] = React.useState(defaultStart) // const [endDate, setEndDate] = React.useState(defaultEnd) const [folderOpen, setFolderOpen] = React.useState(false); const stickyStyle = { position: 'sticky' as const, top: 0, zIndex: 1000, backgroundColor: 'var(--primaryBackground)' }; const attributeList: string[] = React.useMemo(() => { return props.attributeList || props.fields.map(a => a.name) }, [props.attributeList, props.fields]) const RawOriginalData: Attribute[] = React.useMemo(() => { if (!props.features) return []; return features2Data(props.features, attributeList) || []; function features2Data(features: Graphic[], shortListOfAttributes: string[]): Attribute[] { // Create an object to store the statistical summary for each attribute const attributeSummary: { [key: string]: { [value: string]: number } } = {}; // Iterate through the features array features.forEach((feature) => { // Iterate through each attribute in the 'attributes' object of the feature // eslint-disable-next-line @typescript-eslint/no-unsafe-argument for (const [key, value] of Object.entries(feature.attributes)) { if (shortListOfAttributes.includes(key)) { if (value === null || value === undefined) continue; if (!attributeSummary[key]) { attributeSummary[key] = {}; } let valueKey: string; const fieldType = props.fields.find(obj => obj.name == key)?.type; if (fieldType === "date") { const epoch = Number(value); if (!epoch || epoch <= 0) continue; valueKey = dayjs.unix(epoch / 1000).format("DD/MM/YYYY"); } else if (fieldType && ['integer', 'number', 'float', 'short', 'long', 'big integer'].includes(fieldType)) { // Numeric: value passed null check above, but still guard NaN const num = Number(value); if (isNaN(num)) continue; valueKey = String(value); } else { // String: reject known meaningless values valueKey = String(value).trim(); const meaningless = ['', 'null', 'undefined', '{}', 'N/A', 'n/a', '', '']; if (meaningless.includes(valueKey)) continue; } if (!attributeSummary[key][valueKey]) { attributeSummary[key][valueKey] = 0; } attributeSummary[key][valueKey]++; } } }); // Now we need to structure the data according to the `Attribute` interface const result: Attribute[] = []; // Convert the summary into the desired format for (const [attributeName, valueCountMap] of Object.entries(attributeSummary)) { const uniqValues: fieldValue[] = Object.entries(valueCountMap).map(([value, count]) => ({ value: value, count: count, })); // Create the Attribute object const attribute: Attribute = { name: attributeName, uniqValue: uniqValues, }; result.push(attribute); } return result; } }, [attributeList, props.features, props.fields]) // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const originalData: Attribute[] = React.useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument let result = props.fields.filter(obj => !attributeList || attributeList.length === 0 || attributeList.includes(obj.name)) // Filter only if nameList is valid .filter(obj => { if (RawOriginalData.length == 0) { return true } let matchingRawDataValues = RawOriginalData.find(item => item.name === obj.name)?.uniqValue; let meanless = ['{}', ' ', 'null'] return matchingRawDataValues !== undefined && matchingRawDataValues.length > 0 && !matchingRawDataValues.every((val) => meanless.includes(val.value)) }) .map(obj => { let newObj: Attribute = { name: obj.name }; if (obj.alias) { newObj.alias = obj.alias; } if (obj.type !== "string") { newObj.type = obj.type; } newObj.visible = true; newObj.display = false; if (RawOriginalData?.length > 0) { let matchingRawData = RawOriginalData.find(item => item.name === obj.name); if (matchingRawData && matchingRawData.uniqValue) { matchingRawData.uniqValue.sort((a, b) => { if (a.count === b.count) { return a.value.localeCompare(b.value); } return b.count - a.count; }); newObj.uniqValue = matchingRawData.uniqValue; } } return newObj; }); return result; }, [RawOriginalData, attributeList, props.fields]) const DataRef = React.useRef(originalData) const recordTable: object[] = React.useMemo(() => { return props.features?.map(feature => { const filteredAttributes = {}; for (const key of attributeList) { if (key in feature.attributes) { const isDate = originalData.find(attri => attri.name === key)?.type == 'date'; const rawVal = feature.attributes[key]; if (isDate) { const epoch = Number(rawVal); filteredAttributes[key] = (rawVal !== null && rawVal !== undefined && epoch > 0) ? epoch2MelbDate(epoch / 1000) : null; // keep as null, generateAttributeSummary will skip it } else { filteredAttributes[key] = rawVal; } } } return filteredAttributes; }) || []; }, [attributeList, originalData, props.features]) if (data === undefined) { setProperty("data" as any, originalData); } const findEarliestAndLatestDatesForAttribute = (attribute: Attribute) => { if (attribute.type !== "date") { // Return null if the attribute is not of type 'date' return null; } let earliestDate: number, latestDate: number if (attribute.uniqValue && Number(attribute.uniqValue[0].value) && Number(attribute.uniqValue[0].value) > 0) { const values = attribute.uniqValue.map(obj => Number(obj.value)); earliestDate = Math.min(...values); latestDate = Math.max(...values); } else { const dates = attribute.uniqValue?.map((entry) => entry.value) || []; // Extract date strings // Convert to Date objects for comparison const dateObjects: Date[] = dates.map((date) => { const [day, month, year] = date.split('/').map(Number); return new Date(year, month - 1, day); // JS months are 0-indexed }); // Find the earliest and latest dates by converting to numeric timestamps earliestDate = Math.min(...dateObjects.map((date) => date.getTime())); latestDate = Math.max(...dateObjects.map((date) => date.getTime())); } // Return results as epoch time numbers return { name: attribute.name, earliest: earliestDate, latest: latestDate, } }; // Handle search input changes const handleSearchChange = (event: React.ChangeEvent) => { setProperty("searchQuery" as any, event.target.value); // Update search query }; function layerNameCreator(data: Attribute[], sqlConnector: string) { const clauses = data.map((attribute) => { if (attribute.selection && attribute.selection.length > 0) { const values = attribute.selection.join(','); return `${attribute.name}: ${values}`; } return null; }).filter(clause => clause !== null); const result = clauses.join(` ${sqlConnector == 'AND' ? "&" : "+"} `); return result } function IsOtherRulesEmpty(specificAttributeName: string) { return data && data.every(attribute => { if (attribute.name === specificAttributeName) { return true; } return (!attribute.selection || attribute.selection.length == 0) }); } const handleSqlRule = (event: React.ChangeEvent) => { //openAttriName.current = '' setProperty("sqlRule" as any, (event.target as HTMLInputElement).value); const allSelectionsEmpty = data && data.every(attribute => attribute.selection === null || attribute.selection === undefined || (Array.isArray(attribute.selection) && attribute.selection.length === 0) ); if (allSelectionsEmpty) { return } if ((event.target as HTMLInputElement).value == 'OR') { //converting from AND to OR, //1. summary needs to be recaculated from backend OR engine, and get a larger number //2. if any atttribute is displayed, get original uniq values and re-render if (summary !== 0) { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, 'OR').length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(data, 'OR') })) } } if (openAttriName.current !== '') { if (recordTable !== undefined && recordTable.length > 0) { localDisplayLayer(originalData.find(obj => obj.name == openAttriName.current) || data[0], undefined, true) } else { raiseEvent("clicked", JSON.stringify({ request: 'displayLayer', value: openAttriName.current, rule: '1=1' })); } } } else { //converting from OR to AND, //1. get new sum //2. if some attri open, reload unqivalues if (openAttriName.current == '') { if (summary !== 0 || !allSelectionsEmpty) { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, 'AND').length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(data, 'AND') })) } } } else { if (recordTable !== undefined && recordTable.length > 0) { localDisplayLayer(data?.find(obj => obj.name == openAttriName.current) || data[0], undefined, false, 'AND') } else { raiseEvent("clicked", JSON.stringify({ request: 'displayLayer', value: openAttriName.current, rule: generateSQLSummary(data, 'AND', openAttriName.current) })) } if (summary !== 0 || !allSelectionsEmpty) { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, 'AND').length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(data, 'AND') })) } } } } raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(data, (event.target as HTMLInputElement).value) })) }; // const handleSearchValueChange = (attri:Attribute,event: React.ChangeEvent) => { const handleSearchValueChange = (attri: Attribute, event: React.ChangeEvent) => { // setProperty("searchValue" as any,event.target.value); // Update search query if (event.target.value) { const searchValueResults = filterValue(attri, event.target.value); setProperty("filteredValues" as any, searchValueResults) setProperty("searchValue" as any, event.target.value) } else { setProperty("searchValue" as any, event.target.value) setProperty("filteredValues" as any, []) } }; const handleSelectAll = (event: React.MouseEvent, attributeName: string) => { event.stopPropagation(); // Prevents the ListItemButton click event const selectAll = (allButtonText == 'Select All') const newText = selectAll ? 'Deselect All' : 'Select All' setProperty("allButtonText" as any, newText) const dataCopy = structuredClone(data) let updatedDataCopy: Attribute[] if (selectAll) { if (filteredValues.length > 0) { //select all search result updatedDataCopy = dataCopy.map((attribute) => { if (attribute.name === attributeName) { const filteredValuesSet = new Set(filteredValues.map(item => item.value)); const allValues = attribute.uniqValue ?.filter(item => filteredValuesSet.has(item.value)) .map(item => item.value) || []; const totalCount = attribute.uniqValue?.reduce((sum, item) => { return (!attribute.selection?.includes(item.value) && filteredValuesSet.has(item.value)) ? sum + (item.count || 0) : sum; }, 0) || 0; if (sqlRule == 'AND') { const result = (attribute.selection && attribute.selection.length > 0) ? (summary + totalCount) : IsOtherRulesEmpty(attributeName) ? totalCount : Math.min(summary, totalCount) tempSummary.current = result setProperty("summary" as any, result); numberRecord.current[attributeName] = tempSummary.current } else { tempSummary.current = Math.max(summary, totalCount) const newSummary = Math.max(summary, totalCount) setProperty("summary" as any, newSummary) numberRecord.current[attributeName] = tempSummary.current } return { ...attribute, selection: Array.from(new Set([...(allValues ?? []), ...(attribute.selection ?? [])])), }; } return attribute; }) setProperty("data" as any, updatedDataCopy); } else { //No search or range - real select all updatedDataCopy = dataCopy.map((attribute) => { if (attribute.name === attributeName) { const allValues = attribute.uniqValue?.map((item) => item.value) || []; const totalCount = attribute.uniqValue?.reduce((sum, obj) => sum + obj.count, 0) || 0; if (IsOtherRulesEmpty(attributeName) || sqlRule == 'AND') { tempSummary.current = totalCount setProperty("summary" as any, totalCount); } else if (!IsOtherRulesEmpty(attributeName)) { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, sqlRule).length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(data, sqlRule) })) } } return { ...attribute, selection: allValues, }; } return attribute; }) setProperty("data" as any, updatedDataCopy); } raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(updatedDataCopy, sqlRule) })) } else { if (filteredValues.length > 0) { const filteredValuesSet = new Set(filteredValues.map(item => item.value)); updatedDataCopy = dataCopy.map((attribute) => { if (attribute.name === attributeName) { const totalCount = attribute.uniqValue?.reduce((sum, item) => { return (filteredValuesSet.has(item.value) && attribute.selection?.includes(item.value)) ? sum + (item.count || 0) : sum; }, 0) || 0; if (!IsOtherRulesEmpty(attributeName) && sqlRule == 'AND' && totalCount == summary) { setProperty("summary" as any, tempPreviousSummary.current) numberRecord.current[attributeName] = tempSummary.current } else { tempSummary.current = summary - totalCount const newSummary = summary - totalCount setProperty("summary" as any, newSummary) numberRecord.current[attributeName] = tempSummary.current } return { ...attribute, selection: attribute.selection?.filter(item => !filteredValuesSet.has(item)) || [], }; } return attribute; }) setProperty("data" as any, updatedDataCopy); if (generateSQLSummary(data, sqlRule, attributeName) == '1=1') { tempSummary.current = 0; tempPreviousSummary.current = 0; } else { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, updatedDataCopy, sqlRule).length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(updatedDataCopy, sqlRule) })) } } raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(updatedDataCopy, sqlRule, attributeName) })) } else { deleteSelection(data?.find(attri => attri.name == attributeName) || data[0]) raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(data, sqlRule, attributeName) })) } } }; const handleBtnClick = (requestName: string) => { raiseEvent("clicked", JSON.stringify({ request: requestName, rule: generateSQLSummary(data, sqlRule), layerName: layerNameCreator(data, sqlRule) })) } const handleClearRulesClick = () => { const updatedData = data?.map((attribute) => { if (attribute.selection && attribute.selection.length > 0) { attribute.selection = undefined } return attribute } ) setProperty("data" as any, updatedData); setProperty("summary" as any, 0) raiseEvent("clicked", JSON.stringify({ request: 'Clear' })) if (recordTable !== undefined && recordTable.length > 0) { const updateCopy = originalData.map((attribute) => { if (attribute.name == openAttriName.current) { attribute.display = true } else { attribute.display = false } return attribute }) setProperty("data" as any, updateCopy) } } // Handle click on items to raise an event const handleItemClick = (currentAttribute: Attribute, fieldValue: fieldValue) => { const dataCopy = structuredClone(data) const updatedDataCopy = dataCopy.map((attribute) => { if (currentAttribute.name === attribute.name) { const isSelected = attribute.selection?.includes(fieldValue.value); return { ...attribute, selection: isSelected ? (attribute.selection || []).filter((value) => value !== fieldValue.value) : [...(attribute.selection || []), fieldValue.value] }; } return attribute; }); setProperty("data" as any, updatedDataCopy); if (sqlRule == 'AND') { if (!IsOtherRulesEmpty(currentAttribute.name)) { if (!currentAttribute.selection || currentAttribute.selection.length == 0) { tempSummary.current = 0 } if (currentAttribute.selection?.includes(fieldValue.value)) { tempSummary.current -= fieldValue.count setProperty("summary" as any, tempSummary.current == 0 ? tempPreviousSummary.current : tempSummary.current); numberRecord.current[currentAttribute.name] = tempSummary.current } else { tempSummary.current += fieldValue.count setProperty("summary" as any, tempSummary.current); numberRecord.current[currentAttribute.name] = tempSummary.current } } else { if (currentAttribute.selection?.includes(fieldValue.value)) { tempSummary.current = summary - fieldValue.count setProperty("summary" as any, summary - fieldValue.count); numberRecord.current[currentAttribute.name] = tempSummary.current } else { tempSummary.current = summary + fieldValue.count setProperty("summary" as any, summary + fieldValue.count); numberRecord.current[currentAttribute.name] = tempSummary.current } } } else { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, updatedDataCopy, 'OR').length setProperty("summary" as any, newCount) } else { if (!currentAttribute.selection || currentAttribute.selection.length == 0) { tempSummary.current = 0 const values: number[] = Object.values(numberRecord.current); const maxNumber = Math.max(...values); tempPreviousSummary.current = maxNumber } if (currentAttribute.selection?.includes(fieldValue.value)) { tempSummary.current -= fieldValue.count setProperty("summary" as any, tempSummary.current > tempPreviousSummary.current ? tempSummary.current : tempPreviousSummary.current); numberRecord.current[currentAttribute.name] = tempSummary.current } else { tempSummary.current += fieldValue.count if (tempSummary.current > summary) { setProperty("summary" as any, tempSummary.current); } numberRecord.current[currentAttribute.name] = tempSummary.current } } } raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(updatedDataCopy, sqlRule) })) }; const generateSQLSummary = (data: Attribute[], logicalOperator: string, exception?: string | string[]) => { const isException = (name: string) => { if (Array.isArray(exception)) { return exception.includes(name); } return name === exception; }; function generateDateSQL(melbourneDates: string[], epochField: string) { const convertToUTC = (melbourneTime: string) => { return dayjs.tz(melbourneTime, 'Australia/Melbourne').utc().format('YYYY-MM-DD HH:mm:ss'); }; const sqlConditions = melbourneDates.map(dateStr => { const startTimestamp = convertToUTC(`${dateStr} 00:00:00`); const endTimestamp = convertToUTC(`${dateStr} 23:59:59`); return `(${epochField} BETWEEN TIMESTAMP '${startTimestamp}' AND TIMESTAMP '${endTimestamp}')`; }); const combinedConditions = "(" + sqlConditions.join(' OR ') + ")"; return combinedConditions } const clauses = data.map((attribute) => { if (!isException(attribute.name) && attribute.selection && attribute.selection.length > 0) { let values: string[] | string; if (attribute.type === 'date') { if (attribute.selection.length < 11) { values = attribute.selection.map((value) => { return dayjs(value, 'DD/MM/YYYY').format('YYYY-MM-DD') }) return generateDateSQL(values, attribute.name) } else { const convertToUTC = (melbourneTime: string) => { return dayjs.tz(melbourneTime, 'Australia/Melbourne').utc().format('YYYY-MM-DD HH:mm:ss'); }; const timeArray = attribute.selection.map((value) => { return dayjs(value, 'DD/MM/YYYY') }) const minDate = timeArray.reduce((min, current) => (current.isBefore(min) ? current : min)).format('YYYY-MM-DD'); const maxDate = timeArray.reduce((max, current) => (current.isAfter(max) ? current : max)).format('YYYY-MM-DD'); const startTimestamp = convertToUTC(`${minDate} 00:00:00`); const endTimestamp = convertToUTC(`${maxDate} 23:59:59`); return `${attribute.name} >= '${startTimestamp}' AND ${attribute.name} <= '${endTimestamp}'`; } } else { const likeConditions: string[] = []; const inValues: string[] = []; attribute.selection.forEach(value => { if (value.length > 72) { const first72Chars = value.slice(0, 72); // Get the first 72 characters likeConditions.push(`${attribute.name} LIKE '${first72Chars}%'`); } else { inValues.push(`'${value}'`); // Use value as-is for short strings } }); const inClause = inValues.length > 0 ? `${attribute.name} IN (${inValues.join(', ')})` : ''; const likeClause = likeConditions.join(' OR '); if (inClause && likeClause) { return `(${inClause} OR ${likeClause})`; } else if (inClause) { return `(${inClause})`; } else if (likeClause) { return `(${likeClause})`; } else { return null; // Return an empty string if there are no conditions } } } return null; }).filter(clause => clause !== null); const result = clauses.length > 1 ? clauses.join(` ${logicalOperator} `) : clauses.length > 0 ? clauses[0].replace(/^\(|\)$/g, '') : '' return result.length > 2 ? result : '1=1' }; interface Condition { type: 'IN' | 'LIKE' | 'BETWEEN'; // Condition type: IN, LIKE, or BETWEEN field: string; // The field (column) to apply the condition on values?: any[]; // Values for the IN condition pattern?: string; // Pattern for the LIKE condition start?: number | string | Date; // Start value for BETWEEN condition (number, string, or Date) end?: number | string | Date; // End value for BETWEEN condition (number, string, or Date) } class SimpleSQLWhereEngine { private data: object[]; // Data is an array of objects constructor(data: Record[]) { this.data = data; } // Utility function to convert date strings to epoch time (milliseconds) private parseDate(dateString: string): { start: number, end: number } { // Format: dd/mm/yyyy (like '25/01/2024') const [day, month, year] = dateString.split('/').map(Number); // Create a date for the start of the day (00:00:00) const startOfDay = new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); // Create a date for the end of the day (23:59:59) const endOfDay = new Date(year, month - 1, day, 23, 59, 59, 999).getTime(); return { start: startOfDay, end: endOfDay }; } // WHERE IN (specific values) condition private in(field: string, values: any[], isDate: boolean = false): Record[] { return this.data.filter(row => { const fieldValue = row[field]; if (isDate && typeof fieldValue === 'number') { // If isDate is true and the field is a number (epoch milliseconds), compare as dates return values.some(value => { if (typeof value === 'string') { const { start, end } = this.parseDate(value); return fieldValue >= start && fieldValue <= end; // Check if the field value is within the day range } return false; }); } // Default behavior for non-date fields or when isDate is false return values.includes(fieldValue); }); } // WHERE LIKE (for string matching) private like(field: string, pattern: string): Record[] { const regex = new RegExp(pattern.replace('%', '.*')); // Convert 'sth%' to regex pattern // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return this.data.filter(row => regex.test(row[field])); } // WHERE BETWEEN (for timestamps or numerical ranges) private between(field: string, start: number | string | Date, end: number | string | Date): Record[] { return this.data.filter(row => { const value = row[field]; return value >= start && value <= end; }); } // General WHERE function that can handle all conditions public where(condition: Condition, isDate: boolean = false): Record[] { if (condition.type === 'IN' && condition.values) { return this.in(condition.field, condition.values, isDate); } else if (condition.type === 'LIKE' && condition.pattern) { return this.like(condition.field, condition.pattern); } else if (condition.type === 'BETWEEN' && condition.start !== undefined && condition.end !== undefined) { return this.between(condition.field, condition.start, condition.end); } return this.data; // Default return if no condition is met } } function filterFeatures(features: Record[], attributes: Attribute[], logic: string, exception?: string | string[]): Record[] { // eslint-disable-next-line prefer-const let engine = new SimpleSQLWhereEngine(features); let filteredFeatures = features; // Start with all features const hasValidSelections = attributes.some(attribute => attribute.name !== exception && attribute.selection && attribute.selection.length > 0); if (!hasValidSelections) { return features; // Display all features if no valid selections } if (logic === 'AND') { attributes.forEach(attribute => { if ((attribute.name !== exception && !exception?.includes(attribute.name)) && attribute.selection && attribute.selection.length > 0) { const condition: Condition = { type: 'IN', field: attribute.name, values: attribute.selection }; filteredFeatures = engine.where(condition, attribute.type === 'date'); // Filter progressively on each condition engine = new SimpleSQLWhereEngine(filteredFeatures); } }); } else if (logic === 'OR') { let tempFeatures: Record[] = []; if (attributes.some(obj => obj.selection && obj.selection.length > 0)) { attributes.forEach(attribute => { if (attribute.name !== exception && !exception?.includes(attribute.name) && attribute.selection && attribute.selection.length > 0) { const condition: Condition = { type: 'IN', field: attribute.name, values: attribute.selection }; tempFeatures = tempFeatures.concat(engine.where(condition)); // Concatenate results } }); // Remove duplicates filteredFeatures = tempFeatures.filter((item, index) => tempFeatures.indexOf(item) === index); } else { filteredFeatures = [] } } return filteredFeatures; } function generateAttributeSummary(features: object[], attributeName: string, type: string): fieldValue[] { // Create an object to store the statistical summary for the attribute const attributeSummary: { [key: string]: number } = {}; // Iterate through the features array features.forEach((feature) => { const value = feature[attributeName]; if (value === null || value === undefined || value === '') return; if (value) { // Count the occurrence of each value for the attribute if (!attributeSummary[value]) { attributeSummary[value] = 0; } attributeSummary[value]++; } }); // Convert the summary object to the desired array format const rawResult: fieldValue[] = Object.entries(attributeSummary).map(([value, count]) => ({ value: type == 'date' && Number(value) > 0 ? epoch2MelbDate(Number(value) / 1000) : value, count: count })); if (type !== 'date' || !Number(rawResult[0].value)) { return rawResult } else { const result = groupByMelbourneDate(rawResult) return result } } function epoch2MelbDate(epoch: any) { return dayjs.unix(Number(epoch)).tz('Australia/Melbourne').format('DD/MM/YYYY'); } function formatUniqValues(attri: Attribute) { let newAttriData: fieldValue[] if (attri.type == 'date' && attri.uniqValue && Number(attri.uniqValue[0].value) > 0) { const values = attri.uniqValue.map(obj => Number(obj.value)); minDate.current = Math.min(...values) / 1000; maxDate.current = Math.max(...values) / 1000; setProperty("startDate" as any, dayjs.unix(Math.min(...values) / 1000)) setProperty("endDate" as any, dayjs.unix(Math.max(...values) / 1000)) newAttriData = groupByMelbourneDate(attri.uniqValue) } else { newAttriData = attri.uniqValue || [] } return newAttriData } function localDisplayLayer(attri: Attribute, exception?: string, noLimit?: boolean, Rule?: string) { if (recordTable !== undefined && recordTable.length > 0) { let result: Attribute[] if (noLimit) { const dataCopy = structuredClone(data) result = dataCopy.map(item => item.name === attri.name ? { ...item, display: true, uniqValue: item.selection && item.selection.length > 0 ? sortItemsBySelection(formatUniqValues(originalData.find(attribute => attribute.name == item.name) || data[0]), item.selection) : formatUniqValues(originalData.find(attribute => attribute.name == item.name) || data[0]) } : { ...item, display: false } ); setProperty("data" as any, result); } else { let queryFeatures: object[] = [] if (exception) { queryFeatures = filterFeatures(recordTable, data, Rule || sqlRule, [exception, attri.name]) } else { queryFeatures = filterFeatures(recordTable, data, Rule || sqlRule, attri.name) } const queryUniqValue = generateAttributeSummary(queryFeatures, attri.name, attri.type || 'string') queryUniqValue.sort((a, b) => { if (a.count === b.count) { return a.value.localeCompare(b.value); } return b.count - a.count; }); const dataCopy = structuredClone(data) result = dataCopy.map(item => item.name === attri.name ? { ...item, display: true, uniqValue: item.selection && item.selection.length > 0 ? sortItemsBySelection(queryUniqValue, item.selection) : queryUniqValue } : { ...item, display: false } ); setProperty("data" as any, result); } if (attri.type == 'date') { minDate.current = Math.floor((findEarliestAndLatestDatesForAttribute(attri)?.earliest || 946700000) / 1000) maxDate.current = Math.floor((findEarliestAndLatestDatesForAttribute(attri)?.latest || 2209004000) / 1000) setProperty("startDate" as any, dayjs.unix(minDate.current)) setProperty("endDate" as any, dayjs.unix(maxDate.current)) } openAttriName.current = attri.name // const oldSelection = result.find(obj => obj.name === openAttriName.current)?.selection || [] // const updatedAttri = result.find(obj => obj.name === attri.name) || result[0] // if (openAttriName.current == attri.name && oldSelection.length > 0 && searchValue.length > 0) { // const searchValueResults = filterValue(updatedAttri, searchValue); // setProperty("filteredValues" as any, searchValueResults) // } // openAttriValues.current = updatedAttri } } // // Handle category toggle open/close manually const handleAttributeClick = (attri: Attribute) => { setProperty("filteredValues" as any, []) setShowAll(false) setProperty("searchValue" as any, ''); setProperty("minValue" as any, -Infinity) setProperty("maxValue" as any, Infinity) tempPreviousSummary.current = summary if (!attri.display) { if (recordTable !== undefined && recordTable.length > 0) { localDisplayLayer(attri, undefined, sqlRule === 'OR') } else { raiseEvent("clicked", JSON.stringify({ request: 'displayLayer', value: attri.name, rule: sqlRule == 'AND' ? generateSQLSummary(data, sqlRule, attri.name) : '1=1' })) } } else if (attri.display) { const dataCopy = structuredClone(data) setSortModes(prev => { const copy = { ...prev }; delete copy[attri.name]; return copy; }); let result: Attribute[] if (recordTable !== undefined && recordTable.length > 0) { result = dataCopy.map(item => item.name === attri.name ? { ...item, display: false } : item ); } else { result = dataCopy.map(item => item.name === attri.name ? { ...item, display: false, uniqValue: undefined } : item ); } setProperty("data" as any, result); openAttriName.current = '' props.openedAttribute = undefined } }; const handleShowAllClick = () => { setShowAll(!showAll); }; function deleteSelection(attri: Attribute) { function removeAnotherRule() { const updatedData = data?.map((attribute) => { if (attribute.name === attri.name) { return { ...attribute, selection: undefined, uniqValue: recordTable && recordTable.length > 0 ? attri.uniqValue : undefined }; } if (attribute.name === openAttriName.current && tickedBeforeNewQuery.current !== undefined) { return { ...attribute, selection: tickedBeforeNewQuery.current, }; } return attribute; }) setProperty("data" as any, updatedData); } if (IsOtherRulesEmpty(attri.name)) { setProperty("summary" as any, 0); tempSummary.current = 0 numberRecord.current[attri.name] = 0 if (openAttriName.current !== '') { if (recordTable !== undefined && recordTable.length > 0) { localDisplayLayer(data?.find(attribute => attribute.name == openAttriName.current) || data[0], undefined, true) } else { raiseEvent("clicked", JSON.stringify({ request: 'displayLayer', value: openAttriName.current, rule: '1=1' })) } } const updatedData = data?.map((attribute) => { if (attribute.name === attri.name) { return { ...attribute, selection: [], }; } return attribute; }) setProperty("data" as any, updatedData); } else { if (openAttriName.current == attri.name) { if (data?.find(attribute => attribute.name == attri.name)) { const updatedData = data?.map((obj) => { if (obj.name === attri.name) { return { ...obj, selection: undefined }; } return obj }) setProperty("data" as any, updatedData); } if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, sqlRule, attri.name).length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', rule: generateSQLSummary(data, sqlRule, attri.name) })) } } else { const targetObject = data?.find(obj => obj.name == openAttriName.current); if (targetObject && targetObject.uniqValue) { tickedBeforeNewQuery.current = targetObject.selection if (recordTable !== undefined && recordTable.length > 0) { localDisplayLayer(data?.find(obj => obj.name == openAttriName.current) || data[0], attri.name) const newCount = filterFeatures(recordTable, data, sqlRule, attri.name).length setProperty("summary" as any, newCount) } else { raiseEvent("clicked", JSON.stringify({ request: 'displayLayer', value: openAttriName.current, rule: generateSQLSummary(data, sqlRule, [openAttriName.current, attri.name]) })) } //origianlly newQueery removeAnotherRule() } else { if (recordTable !== undefined && recordTable.length > 0) { const newCount = filterFeatures(recordTable, data, sqlRule, attri.name).length setProperty("summary" as any, newCount) const updatedData = data?.map((obj) => { if (obj.name === attri.name) { return { ...obj, selection: undefined }; } return obj }) setProperty("data" as any, updatedData); } else { raiseEvent("clicked", JSON.stringify({ request: 'sumOnly', value: attri.name, rule: generateSQLSummary(data, sqlRule, attri.name) })) } } } } } const handleDeleteSelection = (attri: Attribute) => { deleteSelection(attri) raiseEvent("clicked", JSON.stringify({ request: 'updateMap', rule: generateSQLSummary(data, sqlRule, attri.name) })) }; function detectNumberAttribute(attribute) { const name = attribute.name.toLowerCase();; if (name.includes("id") || name.includes("name") || name.includes("mobile") || name.includes("phone")) { return false; } if (attribute.uniqValue) { const phoneNumberPattern = /^04\d{8}$/; let allPhoneNumbers = true; for (let i = 0; i < 5; i++) { if (i >= attribute.uniqValue.length) break; // In case there are less than 5 elements const value: string = attribute.uniqValue[i].value; if (isNaN(Number(value))) { return false; } if (!phoneNumberPattern.test(value) && allPhoneNumbers == true) { allPhoneNumbers = false } } if (allPhoneNumbers) { return false } return true; } return false; } function findMinMax(attribute) { let min = Infinity; let max = -Infinity; if (attribute.uniqValue) { attribute.uniqValue.forEach(item => { const num = Number(item.value); if (!isNaN(num)) { if (num < min) min = num; if (num > max) max = num; } }); return { min: Math.floor(min * 100) / 100, max: Math.floor(max * 100) / 100 }; } return { min: -Infinity, max: Infinity }; } const toggleFolder = () => setFolderOpen(!folderOpen); const renderAttribute = (attribute: Attribute): React.ReactNode => { // --- sort mode for this attribute --- const defaultSort = attribute.type === 'date' ? 'late-early' : 'count'; const sortMode = sortModes[attribute.name] ?? defaultSort; const sortOptions: Record = { date: ['late-early', 'early-late', 'count'], number: ['count', 'big-small', 'small-big'], string: ['count', 'a-z', 'z-a'], }; const fieldKind = attribute.type === 'date' ? 'date' : (attribute.type && ['integer', 'number', 'float', 'short', 'long', 'big integer'].includes(attribute.type)) || detectNumberAttribute(attribute) ? 'number' : 'string'; const modes = sortOptions[fieldKind]; const modeLabels: Record = { 'count': 'By count', 'a-z': 'A → Z', 'z-a': 'Z → A', 'late-early': 'Latest first', 'early-late': 'Earliest first', 'big-small': 'Biggest first', 'small-big': 'Smallest first', }; function applySortMode(values: fieldValue[]): fieldValue[] { const copy = [...values]; switch (sortMode) { case 'count': return copy.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); case 'a-z': return copy.sort((a, b) => a.value.localeCompare(b.value)); case 'z-a': return copy.sort((a, b) => b.value.localeCompare(a.value)); case 'late-early': return copy.sort((a, b) => { const da = dayjs(a.value, 'DD/MM/YYYY'), db = dayjs(b.value, 'DD/MM/YYYY'); return db.valueOf() - da.valueOf(); }); case 'early-late': return copy.sort((a, b) => { const da = dayjs(a.value, 'DD/MM/YYYY'), db = dayjs(b.value, 'DD/MM/YYYY'); return da.valueOf() - db.valueOf(); }); case 'big-small': return copy.sort((a, b) => Number(b.value) - Number(a.value)); case 'small-big': return copy.sort((a, b) => Number(a.value) - Number(b.value)); default: return copy; } } let ISnumberAttribute = false let attributeMin = -Infinity let attributeMax = Infinity if (attribute.uniqValue && attribute.uniqValue.length > 0) { attributeMin = findMinMax(attribute).min attributeMax = findMinMax(attribute).max const numberTypes = ['integer', 'number', 'float', 'short', 'long', 'big integer']; ISnumberAttribute = numberTypes.includes(attribute?.type || '--') || detectNumberAttribute(attribute) } let displayedValues: fieldValue[] = [] let displayedFilterValues: fieldValue[] = [] let limitInUse if (recordTable !== undefined && recordTable.length > 0) { limitInUse = (attribute.type == 'date' && (dayjs.unix(minDate.current).format('DD/MM/YYYY') != startDate.format('DD/MM/YYYY') || (dayjs.unix(maxDate.current).format('DD/MM/YYYY') != endDate.format('DD/MM/YYYY')))) || searchValue.length > 0 || (minValue !== -Infinity || maxValue !== Infinity) } else { limitInUse = (attribute.type == 'date' && (dayjs.unix(minDate.current).format('DD/MM/YYYY') != startDate.format('DD/MM/YYYY') || (dayjs.unix(maxDate.current).format('DD/MM/YYYY') != endDate.format('DD/MM/YYYY')))) || searchValue.length > 0 || (minValue !== -Infinity || maxValue !== Infinity) } if (attribute.uniqValue && attribute.uniqValue.length > 0) { const sorted = applySortMode(attribute.uniqValue); displayedValues = (sorted.length < defaultNumber + 2) || showAll ? sorted : sorted.slice(0, defaultNumber); } if (limitInUse && filteredValues.length > 0) { const sorted = applySortMode(filteredValues); displayedFilterValues = ((sorted.length < defaultNumber + 2) || showAll) ? sorted : sorted.slice(0, defaultNumber) } const handleDateRangeChange = (newStart: Dayjs, newEnd: Dayjs) => { setProperty("startDate" as any, newStart || defaultStart); setProperty("endDate" as any, newEnd || defaultEnd) const filterrangeResult = filterDateRange(attribute, newStart, newEnd) setProperty("filteredValues" as any, filterrangeResult) }; const maxStringLength = 28 function itemText(item: fieldValue, type?: string) { let rawResult: string = '' if (type == 'number') { rawResult = ((Math.floor(Number(item.value) * 100) / 100)).toString() } else { rawResult = item.value } return rawResult.length > maxStringLength ? rawResult.substring(0, maxStringLength - 3) + '...' : rawResult; } const chipLabel = attribute.selection && attribute.selection.length > 1 ? `${attribute.selection.length} selected` : attribute.selection && attribute.selection.length > 0 && attribute.type === 'date' ? attribute.selection[0] : attribute.selection && attribute.selection.length > 0 && attribute.selection[0].length > 8 ? `${attribute.selection[0].substring(0, 6)}..` : attribute.selection ? attribute.selection[0] : ''; const chipElement = ( handleDeleteSelection(attribute)} /> ); const exceptions = ['OBJECTID', 'Shape__Area', 'Shape__Length']; if (!exceptions.includes(attribute.name)) { return ( handleAttributeClick(attribute)} sx={{ // Conditionally change background color based on attribute.display backgroundColor: attribute.display ? 'rgba(197, 197, 197, 0.65)' : 'transparent', }} > { // render icon image attribute.name ? ( ) : //on purpose keep not in use code in case ( ) } {//selection chip attribute.selection && attribute.selection.length > 0 ? attribute.selection.length > 1 ? {chipElement} : chipElement : <>} {attribute.display ? : } {attribute.uniqValue ? ( {attribute.type && attribute.type == 'date' && (attribute.uniqValue.length > 6) ? handleDateRangeChange(newstart || startDate, newend || endDate)} sx={{ // Customize the date text color '& .MuiInputBase-input': { color: 'var(--accentIconBorder)', // Change text color for the selected date }, // Customize the start & end labels '& .MuiInputLabel-root': { color: 'var(--accentIconBorder)', // Change label color for both start and end dates }, // Customize the border color of the input '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: 'var(--accentIconBorder)', // Change border color when the input is not focused }, '&:hover fieldset': { borderColor: 'var(--accentIconBorder)', // Change border color on hover }, '&.Mui-focused fieldset': { borderColor: 'var(--accentIconBorder)', // Change border color when focused }, }, }} /> : ISnumberAttribute && (attribute.uniqValue.length > 6) ? //Display String search or number Range , don't display if value amount is limited { if (((val || val == 0) && (val <= maxValue))) { setProperty("minValue" as any, val) }; if (val == undefined || val == null) { setProperty("minValue" as any, -Infinity) }; if ((val || val == 0) && val > maxValue) { setProperty("minValue" as any, maxValue - 1) const filterrangeResult = filterRange(attribute, maxValue - 1, maxValue) setProperty("filteredValues" as any, filterrangeResult) } else { const filterrangeResult = filterRange(attribute, val !== null ? val : -Infinity, maxValue !== Infinity ? maxValue : Infinity) setProperty("filteredValues" as any, filterrangeResult) } }} /> { if ((val || val == 0) && (val >= minValue)) { setProperty("maxValue" as any, val) }; if (val == undefined) { setProperty("maxValue" as any, Infinity) }; if ((val || val == 0) && val < minValue) { setProperty("maxValue" as any, minValue + 1) const filterrangeResult = filterRange(attribute, minValue, minValue + 1) setProperty("filteredValues" as any, filterrangeResult) } else { const filterrangeResult = filterRange(attribute, minValue !== -Infinity ? minValue : -Infinity, val !== null ? val : Infinity || Infinity) setProperty("filteredValues" as any, filterrangeResult) } }} /> : (attribute.uniqValue.length > 6) || searchValue ? ) => handleSearchValueChange(attribute, event)} sx={{ padding: '1px', fontSize: '1.2rem', color: 'var(--alertGrayBackground)', width: '100%' }} /> {searchValue && ( { setProperty("searchValue" as any, ''); setProperty("filteredValues" as any, []) }} size="small" sx={{ position: 'absolute', right: '8px', color: 'var(--alertGrayBackground)' }} > )} : <> } {((!limitInUse && attribute.uniqValue && attribute.uniqValue.length > 2) || (limitInUse && filteredValues.length > 2)) ? {/* Show more/less label */}
{((!limitInUse && attribute.uniqValue && attribute.uniqValue.length >= defaultNumber + 2) || (limitInUse && filteredValues.length >= defaultNumber + 2)) ? defaultNumber && limitInUse ? filteredValues.length - defaultNumber : attribute.uniqValue.length - defaultNumber})`}`} primaryTypographyProps={{ style: { fontWeight: '500' } }} /> : null}
{/* Sort dropdown button */} {attribute.uniqValue && attribute.uniqValue.length > 1 && (() => { const open = sortAnchor?.name === attribute.name; return ( e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}> { e.stopPropagation(); setSortAnchor(open ? null : { el: e.currentTarget, name: attribute.name }); }} sx={{ fontSize: '1rem', opacity: open ? 1 : 0.65, px: 0.5 }} > 📶 {/* Dropdown menu — needs Menu imported from @mui/material or @vertigis/web/ui */} setSortAnchor(null)} onClick={e => e.stopPropagation()} MenuListProps={{ dense: true }} PaperProps={{ sx: { fontSize: '1.2rem', minWidth: 160, boxShadow: 3, } }} transformOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > {modes.map(mode => ( { e.stopPropagation(); setSortModes(prev => ({ ...prev, [attribute.name]: mode })); setSortAnchor(null); }} sx={{ fontSize: '1.2rem' }} > {modeLabels[mode]} ))} ); })()} {/* Select / Deselect All */}
: <>} { // Situation1: Search or range & search result found, only show 'DISPLAYED FILTER VALUE' which is controlled by showall to be all or first 4 (limitInUse && filteredValues.length > 0) ? ( { displayedFilterValues.map((item, idx) => ( item.count > 0 ? item.value.length > maxStringLength ?//control tooltip exist or not handleItemClick(attribute, item)}>
: handleItemClick(attribute, item)}>
: <> ))}
) : (limitInUse && (filteredValues.length == 0)) ? // Situation2: search or range but no result No matching values : attribute.uniqValue?.length == 0 ? // Situation3: No data at beginning No any value for this attribute. // Situation4: no searching or range, show default displayed Values, controlled by showall : {attribute.uniqValue && attribute.uniqValue.length > 0 && displayedValues.map((item, idx) => ( item.count > 0 ? item.value.length > maxStringLength ? handleItemClick(attribute, item)}>
handleItemClick(attribute, item)} />
: handleItemClick(attribute, item)}>
handleItemClick(attribute, item)} />
: <> )) }
}
) : <>}
); } }; function filterData(data: Attribute[], query: string) { const keywords = query.split(/[\s,+;]+/).map((word) => word.trim().toLowerCase()).filter(Boolean); const dataCopy = structuredClone(data) const matchedItems = dataCopy.filter(item => keywords.every(keyword => item.name.toLowerCase().includes(keyword) || item.alias?.toLowerCase().includes(keyword)) ); return matchedItems; } function sortItemsBySelection(items: fieldValue[] | undefined, selection: string[] | undefined) { const selectedSet = new Set(selection); const selected: fieldValue[] = []; const unselected: fieldValue[] = []; if (items) { for (const item of items) { if (selectedSet.has(item.value)) { selected.push(item); } else { unselected.push(item); } } return [...selected, ...unselected]; } else return [] } function filterValue(attri: Attribute, query: string) { const keywords = query.split(/[\s,+;]+/).map((word) => word.trim().toLowerCase()).filter(Boolean); const matchedItems = attri.uniqValue?.filter(item => keywords.every(keyword => item.value.toLowerCase().includes(keyword)) ); return sortItemsBySelection(matchedItems, attri.selection) || []; } function filterRange(attri: Attribute, min: number, max: number) { const matchedItems = attri.uniqValue?.filter(item => Number(item.value) <= max && Number(item.value) >= min ); return sortItemsBySelection(matchedItems, attri.selection) || [] } const groupByMelbourneDate = (data: fieldValue[]) => { const groupedData = new Map(); data.forEach(({ value: value, count: count }) => { const melbourneDate = dayjs(value, "DD/MM/YYYY").tz('Australia/Melbourne').format('DD/MM/YYYY'); if (!groupedData.has(melbourneDate)) { groupedData.set(melbourneDate, { value: melbourneDate, count: 0 }); } groupedData.get(melbourneDate).count += count; }); // Convert the map back to an array of objects return Array.from(groupedData.values()); }; function filterDateRange(attri: Attribute, start: Dayjs, end: Dayjs) { if (!attri.uniqValue) return []; // Handle null or undefined attri.uniqValue const matchedItems = attri.uniqValue.filter((item) => { const itemDate = dayjs(item.value, 'DD/MM/YYYY'); // Parse the date once if (!itemDate.isValid()) return false; // Handle invalid dates gracefully return itemDate.isAfter(start, 'day') && itemDate.isBefore(end, 'day') || itemDate.isSame(start, 'day') || itemDate.isSame(end, 'day'); // Inclusive comparison }); return sortItemsBySelection(matchedItems, attri.selection); } React.useEffect(() => { function filterValue2(attri: Attribute, query: string) { const keywords = query.split(/[\s,+;]+/).map((word) => word.trim().toLowerCase()).filter(Boolean); const matchedItems = attri.uniqValue?.filter(item => keywords.every(keyword => item.value.toLowerCase().includes(keyword)) ); return sortItemsBySelection(matchedItems, attri.selection) || []; } if (props.openedAttribute !== undefined && (openAttriTime.current) !== (props.openedAttribute.timestamp)) { let newAttriData: fieldValue[] | undefined if (props.openedAttribute.data.type == 'date' && props.openedAttribute.data.uniqValue) { const values = props.openedAttribute.data.uniqValue.map(obj => Number(obj.value)); minDate.current = Math.min(...values) / 1000; maxDate.current = Math.max(...values) / 1000; setProperty("startDate" as any, dayjs.unix(Math.min(...values) / 1000)) setProperty("endDate" as any, dayjs.unix(Math.max(...values) / 1000)) newAttriData = groupByMelbourneDate(props.openedAttribute.data.uniqValue) } else { newAttriData = props.openedAttribute.data.uniqValue } if (newAttriData) { newAttriData.sort((a, b) => { if (a.count === b.count) { return a.value.localeCompare(b.value); } return b.count - a.count; }); } const updatedData = data?.map((obj) => { if (obj.name === props.openedAttribute?.data.name) { return { ...props.openedAttribute?.data, uniqValue: obj.selection && obj.selection.length > 0 ? sortItemsBySelection(newAttriData, obj.selection) : newAttriData, selection: obj.selection, // Keep the selection attribute from the previous data hidden: obj.hidden, display: true, }; } return { ...obj, display: false, uniqValue: undefined }; }) setProperty("data" as any, updatedData); const oldSelection = DataRef.current.find(obj => obj.name === openAttriName.current)?.selection || [] if (openAttriName.current == props.openedAttribute?.data.name && oldSelection.length > 0 && searchValue.length > 0) { const searchValueResults = filterValue2(props.openedAttribute.data, searchValue); setProperty("filteredValues" as any, searchValueResults) } const totalDifference = props.openedAttribute.data.uniqValue?.filter(value => oldSelection?.includes(value.value))?.reduce((acc, item) => { const originalItemValues = openAttriValues.current?.uniqValue const originalItem = originalItemValues?.find(oldItem => oldItem.value == item.value); if (originalItem) { acc += item.count - originalItem.count; } return acc; }, 0) || 0; setProperty("summary" as any, summary + totalDifference) openAttriName.current = props.openedAttribute?.data.name openAttriTime.current = props.openedAttribute?.timestamp openAttriValues.current = props.openedAttribute.data } }, [data, props.openedAttribute, searchValue, setProperty, summary]) React.useEffect(() => { const selectedAttribute = data?.find(attribute => attribute.name === openAttriName.current); if (selectedAttribute && (!selectedAttribute.selection || selectedAttribute.selection.length === 0)) { setProperty("allButtonText" as any, 'Select All'); } else if (selectedAttribute && selectedAttribute.selection?.length == selectedAttribute.uniqValue?.length) { setProperty("allButtonText" as any, 'Deselect All'); } }, [data, setProperty]); React.useEffect(() => { if (props.request !== undefined && (requestTime.current) !== (props.request.timestamp)) { if (props.request.requestName == "Clear") { const updatedData = data?.map((attribute) => { if (attribute.selection && attribute.selection.length > 0) { attribute.selection = undefined } return attribute } ) setProperty("data" as any, updatedData); setProperty("summary" as any, 0) } setProperty("rule" as any, generateSQLSummary(data, sqlRule)) setProperty("layerName" as any, layerNameCreator(data, sqlRule)) raiseEvent("clicked", JSON.stringify({ request: props.request.requestName, rule: generateSQLSummary(data, sqlRule), layerName: layerNameCreator(data, sqlRule) })) requestTime.current = props.request.timestamp } }, [data, props.request, raiseEvent, setProperty, sqlRule]); React.useEffect(() => { if (searchQuery == undefined) { setProperty("searchQuery" as any, '') } if (searchValue == undefined) { setProperty("searchValue" as any, '') } if (summary == undefined) { setProperty("summary" as any, 0) } if (sqlRule == undefined) { setProperty("sqlRule" as any, "AND") } if (rule == undefined) { setProperty("rule" as any, "") } if (layerName == undefined) { setProperty("layerName" as any, "") } if (minValue == undefined) { setProperty("minValue" as any, -Infinity) } if (maxValue == undefined) { setProperty("maxValue" as any, Infinity) } if (startDate == undefined) { setProperty("startDate" as any, defaultStart) } if (endDate == undefined) { setProperty("endDate" as any, defaultEnd) } if (filteredValues == undefined) { setProperty("filteredValues" as any, []) } if (sqlRule == undefined) { props.setProperty("sqlRule" as any, "AND") } }, [defaultEnd, defaultStart, endDate, filteredValues, layerName, maxValue, minValue, props, rule, searchQuery, searchValue, setProperty, sqlRule, startDate, summary]); React.useEffect(() => { if (searchQuery && searchQuery.length > 0) { const searchResults = filterData(data, searchQuery); const dataCopy = structuredClone(data) const result = dataCopy.map(item => { const isVisible = searchResults.some(filteredItem => filteredItem.name === item.name); return { ...item, visible: isVisible }; }); setProperty("data" as any, result); } else if (prevSearchValue.current !== '') { const dataCopy = structuredClone(data) const result = dataCopy.map(item => { return { ...item, visible: true }; }); setProperty("data" as any, result); } prevSearchValue.current = searchQuery }, [data, searchQuery, setProperty]) React.useEffect(() => { if (props.newSum !== undefined && props.newSum !== prevSum.current) { setProperty("summary" as any, props.newSum.sum) numberRecord.current['total'] = props.newSum.sum if (!DataRef.current.find(obj => obj.name == openAttriName.current)?.selection || DataRef.current.find(obj => obj.name == openAttriName.current)?.selection?.length == 0) { tempPreviousSummary.current = props.newSum.sum } prevSum.current = props.newSum openAttriName.current == '' const updatedData = data?.map((obj) => { if (obj.name === props.newSum?.attri) { return { ...obj, display: false, uniqValue: undefined, selection: undefined }; } return obj }) setProperty("data" as any, updatedData); } }, [data, props.newSum, setProperty]) return ( <> {data && data.length > 9 ? {searchQuery && ( { setProperty("searchQuery" as any, '') }} size="large" sx={{ position: 'absolute', right: '8px', color: 'var(--alertGrayBackground)' }} > )} : <>} {sqlRule && ( } label={Meet ALL Conditions} /> } label={Meet ANY Conditon} /> )} { props.footerButton ? Selected items amount: {summary} : {props.buttonNaming?.[0] == " " ? <> : } {props.buttonNaming?.slice(1).map((name, index) => name.length > 0 ? ( ) : null )} } { data && data.every(item => item.visible === false) ? No matching attribute name : searchQuery && searchQuery.length > 0 ? {data?.filter(attribute => attribute.visible).map((attribute) => renderAttribute(attribute)) } : {data && data.filter(attribute => !attribute.hidden).map((attribute) => renderAttribute(attribute))} {data && data.every(item => !item.hidden) ? null : <> {folderOpen ? : } {data?.filter(attribute => attribute.hidden).map((attribute) => renderAttribute(attribute))} } } ); } const AttributeFilterElementRegistration: FormElementRegistration = { component: AttributeFilter, getInitialProperties: () => ({ fields: [{ alias: "OBJECTID", display: false, name: "OBJECTID", type: "oid", visible: true }, { alias: "Start Date", display: false, name: "StartDate", type: "date", visible: true }, { alias: "Requestor", display: false, name: "Requestor", type: "string", visible: true }], }), id: "AttributeFilter", }; export default AttributeFilterElementRegistration;