diff --git a/packages/client/src/admin/data/GLStyleEditor/extensions/glStyleAutocomplete.ts b/packages/client/src/admin/data/GLStyleEditor/extensions/glStyleAutocomplete.ts index 8e53e3333..295cdcb4e 100644 --- a/packages/client/src/admin/data/GLStyleEditor/extensions/glStyleAutocomplete.ts +++ b/packages/client/src/admin/data/GLStyleEditor/extensions/glStyleAutocomplete.ts @@ -9,6 +9,7 @@ import { schemeTableau10, interpolatePlasma as interpolateColorScale, } from "d3-scale-chromatic"; +import { EditorState } from "prosemirror-state"; export interface GeostatsAttribute { attribute: string; @@ -37,75 +38,6 @@ export interface GeostatsLayer { attributes: GeostatsAttribute[]; } -export const glStyleAutocomplete = - (layer?: GeostatsLayer) => (context: CompletionContext) => { - let word = context.matchBefore(/[\w-]*/); - let { state, pos } = context, - around = syntaxTree(state).resolveInner(pos), - tree = around.resolve(pos, -1); - for ( - let scan = pos, before; - around === tree && (before = tree.childBefore(scan)); - - ) { - let last = before.lastChild; - if (!last || !last.type.isError || last.from < last.to) break; - around = tree = before; - scan = last.from; - } - - if (!word) { - return null; - } else if ( - around.type.name !== "String" && - around.type.name !== "PropertyValue" && - around.type.name !== "PropertyName" - ) { - if (context.explicit) { - return { - from: word.from, - options: [ - { - label: "Test", - info: "Test", - }, - ], - }; - } - return null; - } else { - const evaluatedContext = evaluateStyleContext(around, context); - if (evaluatedContext) { - const completions = getCompletionsForEvaluatedContext( - evaluatedContext, - layer - ); - if (completions && completions.length) { - return { - from: word.from, - options: completions, - }; - } - } - return null; - } - }; - -function layerTypeFromNode(node: SyntaxNode, context: CompletionContext) { - for (const child of node.getChildren("Property")) { - const propName = child.firstChild; - if (propName) { - const name = context.state.sliceDoc(propName.from, propName.to); - if (name === `"type"` && child.lastChild) { - const propValue = child.lastChild; - return context.state - .sliceDoc(propValue.from, propValue.to) - .replace(/"/g, ""); - } - } - } -} - type LayerType = | "fill" | "line" @@ -128,12 +60,17 @@ interface PropertyNameOption { name: string; doc: string; valueType: PropertyValueType; + defaultValue?: any; +} + +interface RootContext { + type: "Root"; + existingLayerTypes: LayerType[]; + ArrayNode: SyntaxNode; } interface LayerRootPropertyNameStyleContext { type: "LayerRootPropertyName"; - /** Properties that have already been specified */ - usedPropertyNames: string[]; values: PropertyNameOption[]; } @@ -157,15 +94,18 @@ interface PropertyNameStyleContext { type: "PropertyName"; layerType: LayerType; values: PropertyNameOption[]; + ContainerNode: SyntaxNode; + hasValue: boolean; } interface PropertyValueStyleContext { - category: "layout" | "paint"; + category: "layout" | "paint" | "filter"; type: "PropertyValue"; layerType: LayerType; propertyName: string; propertyValueType: PropertyValueType; enumValues?: { value: string; doc: string }[]; + ContainerNode: SyntaxNode; expressions: | false | { @@ -180,7 +120,7 @@ interface ExpressionFnStyleContext { propertyContext: PropertyValueStyleContext; isRootExpression: boolean; matchingValues: { name: string; doc: string; group?: string }[]; - ArrayNode: SyntaxNode; + SurroundingNode: SyntaxNode; } interface ExpressionArgStyleContext { @@ -203,7 +143,81 @@ type EvaluatedStyleContext = | PropertyNameStyleContext | PropertyValueStyleContext | ExpressionFnStyleContext - | ExpressionArgStyleContext; + | ExpressionArgStyleContext + | RootContext; + +export interface InsertLayerOption { + type: LayerType; + label: string; + propertyChoice?: { + property: string; + values?: any[]; + min?: number; + max?: number; + type: PropertyValueType; + }; + layer: any; +} + +export const glStyleAutocomplete = + (layer?: GeostatsLayer) => (context: CompletionContext) => { + let word = context.matchBefore(/[\w-]*/); + let { state, pos } = context, + around = syntaxTree(state).resolveInner(pos), + tree = around.resolve(pos, -1); + for ( + let scan = pos, before; + around === tree && (before = tree.childBefore(scan)); + + ) { + let last = before.lastChild; + if (!last || !last.type.isError || last.from < last.to) break; + around = tree = before; + scan = last.from; + } + + if (!word) { + return null; + } else if ( + around.type.name === "String" || + around.type.name === "PropertyValue" || + around.type.name === "PropertyName" || + (context.explicit && + around.type.name === "Array" && + around.parent?.type.name === "JsonText") + ) { + const evaluatedContext = evaluateStyleContext(around, context); + if (evaluatedContext) { + const completions = getCompletionsForEvaluatedContext( + evaluatedContext, + layer + ); + if (completions && completions.length) { + return { + from: word.from, + options: completions, + }; + } + } + return null; + } + return null; + }; + +function layerTypeFromNode(node: SyntaxNode, context: CompletionContext) { + for (const child of node.getChildren("Property")) { + const propName = child.firstChild; + if (propName) { + const name = context.state.sliceDoc(propName.from, propName.to); + if (name === `"type"` && child.lastChild) { + const propValue = child.lastChild; + return context.state + .sliceDoc(propValue.from, propValue.to) + .replace(/"/g, "") as LayerType; + } + } + } +} const BANNED_PROPERTY_NAMES = ["id", "source", "source-layer"]; @@ -239,6 +253,38 @@ function evaluateStyleContext( node: SyntaxNode, context: CompletionContext ): EvaluatedStyleContext | null { + let styleContext: EvaluatedStyleContext | null = null; + if ( + node.type.name === "Array" && + node.parent?.type.name === "JsonText" && + context.explicit + ) { + styleContext = { + type: "Root", + existingLayerTypes: node + .getChildren("Object") + .map((layer) => { + for (const property of layer.getChildren("Property")) { + const PropertyName = property.getChild("PropertyName"); + if (PropertyName) { + const name = context.state.sliceDoc( + PropertyName.from, + PropertyName.to + ); + if (name === `"type"` && property.lastChild) { + const propValue = property.lastChild; + return context.state + .sliceDoc(propValue.from, propValue.to) + .replace(/"/g, "") as LayerType; + } + } + } + }) + .filter((v) => v) as LayerType[], + ArrayNode: node, + }; + return styleContext; + } // This function only works if the cursor is within a property name or value, // or an expression function name or string argument. if ( @@ -264,16 +310,20 @@ function evaluateStyleContext( const currentValue = context.state .sliceDoc(node.from, node.to) .replace(/[":]/g, ""); + const usedPropertyNames = getPropertyNamesFromObject( + LayerObject, + context + ); // Get a list of all property names that are not already used, as well // as banned property names that should not be suggested like source & id. const excludedPropertyNames = [ ...BANNED_PROPERTY_NAMES, - ...getPropertyNamesFromObject(LayerObject, context), + ...usedPropertyNames, ].filter((v) => v !== currentValue); // Return context indicating that the user is working on a property name // directly on the layer object, and provide a list of all property names // that are not already used. - return { + styleContext = { type: "LayerRootPropertyName", values: Object.entries(styleSpec.layer) .filter(([name]) => !excludedPropertyNames.includes(name)) @@ -285,7 +335,8 @@ function evaluateStyleContext( valueType: details.type, }; }), - } as LayerRootPropertyNameStyleContext; + }; + return styleContext; } else if ( node.type.name === "PropertyValue" || node.type.name === "String" @@ -310,7 +361,7 @@ function evaluateStyleContext( // Return context indicating that the user is working on a property value // directly on the layer object, and provide a list of all possible values // for the property. - return { + styleContext = { type: "LayerRootPropertyValue", propertyName, propertyValueType: spec.type, @@ -321,7 +372,8 @@ function evaluateStyleContext( })) : undefined, expressions: false, - } as LayerRootPropertyValueStyleContext; + }; + return styleContext; } else { // It shouldn't really be possible to reach here, so I'm issuing a // warning for future debugging. @@ -338,36 +390,53 @@ function evaluateStyleContext( if (!layerPropertyName || layerPropertyName === "") { return null; } - if (layerPropertyName === "filter") { - // TODO: work on expression here - return null; - } else if ( + if ( layerPropertyName === "layout" || - layerPropertyName === "paint" + layerPropertyName === "paint" || + layerPropertyName === "filter" ) { const layerType = layerTypeFromNode(LayerObject, context); if (!layerType) { return null; } - const currentProperty = path[INDEXES.SubProperty]; + const currentProperty = + layerPropertyName === "filter" + ? path[INDEXES.LayerProperty] + : path[INDEXES.SubProperty]; const currentPropertyName = getPropertyName(currentProperty, context); const specKey = `${layerPropertyName}_${layerType}`; // @ts-ignore const specBase = styleSpec[specKey]; - if (path.length >= 6) { + if (path.length >= 6 || currentPropertyName === "filter") { // working within a property underneath layout or paint. Possibly within // an expression - if (node.type.name === "PropertyName") { - return { + const usedPropertyNames = getPropertyNamesFromObject( + currentProperty.parent!, + context + ); + if ( + node.type.name === "PropertyName" && + layerPropertyName !== "filter" + ) { + styleContext = { type: "PropertyName", category: layerPropertyName, layerType, - values: Object.keys(specBase).map((name) => ({ - name, - doc: specBase[name].doc, - valueType: specBase[name].type, - })), - } as PropertyNameStyleContext; + hasValue: Boolean( + currentProperty.getChild("PropertyVaue") || + currentProperty.getChild("String") + ), + ContainerNode: currentProperty, + values: Object.keys(specBase) + .map((name) => ({ + name, + doc: specBase[name].doc, + valueType: specBase[name].type, + defaultValue: specBase[name].default, + })) + .filter((v) => !usedPropertyNames.includes(v.name)), + }; + return styleContext; } else if ( node.type.name === "PropertyValue" || node.type.name === "String" @@ -377,16 +446,21 @@ function evaluateStyleContext( // Don't have enough information to provide autocomplete context return null; } - const currentPropertySpec = specBase[currentPropertyName]; + const currentPropertySpec = + currentPropertyName === "filter" + ? // @ts-ignore + styleSpec[`filter_${layerType}`] + : specBase[currentPropertyName]; if (!currentPropertySpec) { // Don't have enough information to provide autocomplete context return null; } - const currentPropertyContext = { + const currentPropertyContext: PropertyValueStyleContext = { type: "PropertyValue", category: layerPropertyName, layerType, propertyName: currentPropertyName, + ContainerNode: currentProperty, propertyValueType: currentPropertySpec.type, enumValues: currentPropertySpec.values ? Object.entries(currentPropertySpec.values).map( @@ -398,7 +472,6 @@ function evaluateStyleContext( : undefined, expressions: currentPropertySpec.expression ? { - supported: true, feature: currentPropertySpec.expression!.parameters.includes( "feature" @@ -411,8 +484,8 @@ function evaluateStyleContext( ), } : false, - } as PropertyValueStyleContext; - if (path.length === 6) { + }; + if (path.length === 6 && currentPropertyName !== "filter") { // working directly on a string value of the property return currentPropertyContext; } else { @@ -447,10 +520,14 @@ function evaluateStyleContext( const isFnName = position === 0; const argPosition = position - 1; - const isRootExpression = path.length === 7; + const isRootExpression = + currentPropertyName === "filter" + ? path.length === 5 + : path.length === 7; if (isFnName) { - return { + console.log(styleSpec.expression_name.values); + styleContext = { type: "ExpressionFn", isRootExpression, propertyContext: currentPropertyContext, @@ -461,8 +538,9 @@ function evaluateStyleContext( doc: (details as any).doc, group: (details as any).group, })), - ArrayNode, - } as ExpressionFnStyleContext; + SurroundingNode: ArrayNode, + }; + return styleContext; } else { // Find any sibling arguments that use a get expression to get an // attribute value @@ -498,7 +576,7 @@ function evaluateStyleContext( context.state.sliceDoc(node.from, node.to).replace(/"/g, "") ); - return { + styleContext = { type: "ExpressionArg", expressionFnName: currentExpressionName, position: argPosition, @@ -509,7 +587,8 @@ function evaluateStyleContext( node.nextSibling && node.nextSibling.type.name !== "]" ), siblingStringArgumentValues, - } as ExpressionArgStyleContext; + }; + return styleContext; } } } else { @@ -610,18 +689,83 @@ function replaceExpressionCompletion( info, expression, detail, - }: { label: string; info?: string; detail?: string; expression: string } + group, + }: { + label: string; + info?: string; + detail?: string; + expression: string; + group?: string; + } ) { + console.log(group); return { label, info, detail, + section: group, apply: (view, completion, from, to) => { + const insertComma = expressionNode.parent!.nextSibling?.type.name === "⚠"; + view.dispatch({ changes: { from: expressionNode.from, to: expressionNode.to, - insert: expression, + insert: insertComma ? expression + ", " : expression, + }, + scrollIntoView: true, + selection: { + anchor: expressionNode.from + expression.length, + }, + }); + formatJSONCommand(view); + }, + } as Completion; +} + +function replacePropertyCompletion( + propertyNode: SyntaxNode, + { + label, + info, + detail, + expression, + section, + }: Completion & { expression: string; section?: string } +) { + return { + label, + info, + detail, + section, + apply: (view, completion, from, to) => { + const errors = propertyNode.getChild("⚠"); + const nextSibling = propertyNode.nextSibling; + const nextSiblingType = nextSibling?.type.name; + let insertComma = Boolean(errors); + if ( + nextSibling && + errors && + propertyNode.to === nextSibling?.from && + nextSiblingType === "}" + ) { + // Inserting a new property at the end of an object, butted up agains + // the ending }. Should not insert a comma. + insertComma = false; + } else if (nextSibling && errors && propertyNode.to < nextSibling?.from) { + // Inserting a new property in the middle or at the begining of an + // object. Should insert a comma. + insertComma = true; + } + view.dispatch({ + changes: { + from: propertyNode.from, + to: errors && insertComma ? errors.from : propertyNode.to, + insert: insertComma ? expression + ", " : expression, + }, + scrollIntoView: true, + selection: { + anchor: propertyNode.from + expression.length + (insertComma ? 1 : 2), }, }); formatJSONCommand(view); @@ -632,8 +776,10 @@ function replaceExpressionCompletion( function getPlaceholderValue( context: PropertyValueStyleContext, index: number, - type: "categorical" | "linear" = "categorical" + type: "categorical" | "linear" = "categorical", + attributeName?: string ) { + const isScaleRank = /rank/i.test(attributeName || ""); const { propertyValueType } = context; if (propertyValueType === "color") { if (type === "categorical") { @@ -642,8 +788,13 @@ function getPlaceholderValue( return `"${interpolateColorScale(index)}"`; } } else if (propertyValueType === "number") { + if (isScaleRank) { + // Flip order of values if scalerank is detected. Common in naturalearth + // Rank goes from 0 to 10, 10 being low rank. + index = 1 - index; + } if (context.propertyName === "circle-radius") { - return `${Math.max(1, index * 50)}`; + return `${Math.max(1, index * 20)}`; } else if (/opacity/.test(context.propertyName)) { return Math.max(Math.min(index, 1), 0); } else { @@ -658,22 +809,64 @@ function getPlaceholderValue( } } +let colorChoiceCounter = 9; + function getCompletionsForEvaluatedContext( styleContext: EvaluatedStyleContext, layer?: GeostatsLayer ) { const completions: Completion[] = []; - if ( + if (styleContext.type === "Root") { + return null; + } else if ( styleContext.type === "LayerRootPropertyName" || styleContext.type === "PropertyName" ) { for (const value of styleContext.values) { - completions.push({ - label: value.name, - detail: value.valueType, - info: value.doc, - // boost: BOOSTS[value.name], - }); + if ( + styleContext.type === "PropertyName" && + value.valueType === "color" && + styleContext.ContainerNode && + !styleContext.hasValue + ) { + completions.push( + replacePropertyCompletion(styleContext.ContainerNode, { + label: value.name, + detail: value.valueType, + info: value.doc, + expression: `"${value.name}": "${ + schemeTableau10[colorChoiceCounter++ % 10] + }"`, + }) + ); + } else if ( + styleContext.type === "PropertyName" && + value.defaultValue !== undefined && + styleContext.ContainerNode && + !styleContext.hasValue + ) { + completions.push( + replacePropertyCompletion(styleContext.ContainerNode, { + label: value.name, + detail: value.valueType, + info: value.doc, + expression: `"${value.name}": ${ + value.valueType === "string" || + value.valueType === "resolvedImage" || + value.valueType === "enum" + ? `"${value.defaultValue}"` + : value.defaultValue + }`, + }) + ); + } else { + completions.push({ + label: value.name, + detail: value.valueType, + info: value.doc, + // boost: BOOSTS[value.name], + }); + } } } else if ( styleContext.type === "LayerRootPropertyValue" || @@ -699,6 +892,15 @@ function getCompletionsForEvaluatedContext( value.name !== "interpolate-lab") || valueType === "color" ) { + if ( + styleContext.isRootExpression && + valueType === "boolean" && + value.group !== "Decision" + ) { + // Show only expressions that retunr a boolean; + continue; + } + console.log("push completions", value); completions.push({ label: value.name, info: value.doc, @@ -710,23 +912,35 @@ function getCompletionsForEvaluatedContext( if (valueType !== "color") { // Unlikely to have valid color value in feature properties completions.push( - ...(layer?.attributes || []).map( - (a) => - ({ - label: `get(${a.attribute})`, - detail: a.type, - apply: (view, completion, from, to) => { - // TODO: move cursor to appropriate position after insertion - view.dispatch({ - changes: { - from: styleContext.ArrayNode.from, - to: styleContext.ArrayNode.to, - insert: `["get", "${a.attribute}"]`, - }, - }); - }, - } as Completion) - ) + ...(layer?.attributes || []) + .filter( + (a) => + a.type === "boolean" || + styleContext.propertyContext.propertyValueType !== "boolean" + ) + .map( + (a) => + ({ + label: `get(${a.attribute})`, + detail: a.type, + apply: (view, completion, from, to) => { + // TODO: add a function to handle these types of changes + // TODO: add a trailing comma if needed to finish property + const expression = `["get", "${a.attribute}"]`; + view.dispatch({ + changes: { + from: styleContext.SurroundingNode.from, + to: styleContext.SurroundingNode.to, + insert: expression, + }, + selection: { + anchor: + styleContext.SurroundingNode.from + expression.length, + }, + }); + }, + } as Completion) + ) ); } @@ -739,7 +953,7 @@ function getCompletionsForEvaluatedContext( // interpolate if (expressions.interpolate) { completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: "interpolate(zoom)", info: "Interpolate expression with pre-populated zoom values", expression: ` @@ -771,7 +985,7 @@ function getCompletionsForEvaluatedContext( if (valueType === "color") { // Add interpolate-hcl completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: "interpolate-hcl(zoom)", info: "Interpolate HCL expression with pre-populated zoom values", expression: ` @@ -803,7 +1017,7 @@ function getCompletionsForEvaluatedContext( // Add interpolate-lab completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: "interpolate-lab(zoom)", info: "Interpolate lab expression with pre-populated zoom values", expression: ` @@ -835,7 +1049,7 @@ function getCompletionsForEvaluatedContext( } } completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: "step(zoom)", info: "Step expression with pre-populated zoom values", expression: ` @@ -858,7 +1072,7 @@ function getCompletionsForEvaluatedContext( // add match if (attribute.type === "boolean" || attribute.type === "string") { completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: `match(${attribute.attribute})`, info: "Match expression with pre-populated values", detail: `${attribute.type} ${attribute.count} values`, @@ -922,7 +1136,7 @@ function getCompletionsForEvaluatedContext( }, [] as number[]); } completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: `interpolate(${attribute.attribute}) quantile`, info: "Interpolate expression with pre-populated values, using 10 quantiles", detail: `${attribute.type} ${attribute.min}-${attribute.max}`, @@ -940,7 +1154,8 @@ function getCompletionsForEvaluatedContext( return `${q},\n ${getPlaceholderValue( styleContext.propertyContext, i / quantiles.length, - "linear" + "linear", + attribute.attribute )}`; }) .join(",\n")} @@ -949,7 +1164,7 @@ function getCompletionsForEvaluatedContext( ); } completions.push( - replaceExpressionCompletion(styleContext.ArrayNode, { + replaceExpressionCompletion(styleContext.SurroundingNode, { label: `interpolate(${attribute.attribute})`, info: "Interpolate expression with pre-populated values", detail: `${attribute.type} ${attribute.min}-${attribute.max}`, @@ -966,18 +1181,80 @@ function getCompletionsForEvaluatedContext( ${getPlaceholderValue( styleContext.propertyContext, 0, - "linear" + "linear", + attribute.attribute )}, ${attribute.max}, ${getPlaceholderValue( styleContext.propertyContext, 1, - "linear" + "linear", + attribute.attribute )} ]`, }) ); } + if (attribute.type === "number" && expressions && expressions.feature) { + let quantiles = attribute.quantiles; + // reduce the number of quantiles to about 5 + if (quantiles && quantiles.length > 5) { + const divisor = Math.floor(quantiles.length / 5); + if (divisor > 1) { + quantiles = quantiles.reduce((acc, q, i) => { + if (i % divisor === 0) { + acc.push(q); + } + return acc; + }, [] as number[]); + } + } + completions.push( + replaceExpressionCompletion(styleContext.SurroundingNode, { + label: `step(${attribute.attribute})`, + info: "Step expression with pre-populated values", + detail: `${attribute.type} ${attribute.min}-${attribute.max}`, + expression: ` + [ + "step", + ["get", "${attribute.attribute}"], + ${getPlaceholderValue( + styleContext.propertyContext, + 0, + "linear", + attribute.attribute + )}, + ${ + quantiles + ? quantiles + .map((q, i) => { + return `${q},\n ${getPlaceholderValue( + styleContext.propertyContext, + i / quantiles!.length, + "linear", + attribute.attribute + )}`; + }) + .join(",\n") + : `${attribute.min}, + ${getPlaceholderValue( + styleContext.propertyContext, + 0, + "linear", + attribute.attribute + )}, + ${attribute.max}, + ${getPlaceholderValue( + styleContext.propertyContext, + 1, + "linear", + attribute.attribute + )}` + } + ]`, + }) + ); + } } } } else if (styleContext.type === "ExpressionArg") { @@ -1111,6 +1388,38 @@ function completionsForPropertyContext(context: PropertyValueStyleContext) { return completions; } +function insertLayerCompletion( + node: SyntaxNode, + { + label, + info, + layer, + }: { + label: string; + info?: string; + layer: string; + } +) { + return { + label, + info, + apply: (view, completion, from, to) => { + view.dispatch({ + changes: { + from: node.to - 1, + to: node.to - 1, + insert: layer, + }, + scrollIntoView: true, + selection: { + anchor: node.from + layer.length, + }, + }); + formatJSONCommand(view); + }, + } as Completion; +} + const INDEXES = { LayerArray: 0, LayerObject: 1, @@ -1129,3 +1438,139 @@ function getRoot( return { jsonTextRoot: node, path: path.reverse() }; } } + +export function getInsertLayerOptions(layer: GeostatsLayer) { + const options: InsertLayerOption[] = []; + if (layer.geometry === "Point" || layer.geometry === "MultiPoint") { + // Add circle types + options.push({ + label: "Simple Circle Marker", + type: "circle", + layer: { + type: "circle", + paint: { + "circle-radius": 5, + "circle-color": schemeTableau10[colorChoiceCounter++ % 10], + }, + layout: {}, + }, + }); + } + if (layer.geometry === "Polygon" || layer.geometry === "MultiPolygon") { + options.push({ + label: "Simple Polygon Fill", + type: "fill", + layer: { + type: "fill", + paint: { + "fill-color": schemeTableau10[colorChoiceCounter++ % 10], + "fill-opacity": 0.8, + }, + layout: {}, + }, + }); + for (const attr of layer.attributes || []) { + if (attr.type === "string") { + options.push({ + label: "Fill color by string property", + propertyChoice: { + property: attr.attribute, + ...attr, + }, + type: "fill", + layer: { + type: "fill", + paint: { + "fill-color": [ + "match", + ["get", attr.attribute], + ...attr.values + .filter((v) => v !== null) + .map((v, i) => { + return [v, schemeTableau10[i % 10]]; + }) + .flat(), + "black", + ], + "fill-opacity": 0.8, + }, + layout: {}, + }, + }); + } + } + for (const attribute of layer.attributes || []) { + if (attribute.type === "number") { + const values: number[] = []; + if (attribute.quantiles?.length) { + let quantiles = attribute.quantiles; + if (quantiles.length === 20) { + quantiles = quantiles.reduce((acc, q, i) => { + if (i % 2 === 0) { + acc.push(q); + } + return acc; + }, [] as number[]); + } + values.push(...quantiles); + } else if (attribute.min && attribute.max) { + values.push(attribute.min); + values.push(attribute.max); + } + if (values.length) { + options.push({ + label: "Color interpolated by number property", + propertyChoice: { + property: attribute.attribute, + ...attribute, + }, + type: "fill", + layer: { + type: "fill", + paint: { + "fill-color": [ + "interpolate-hcl", + ["linear"], + ["get", attribute.attribute], + ...values + .map((v, i) => { + return [v, interpolateColorScale(i / values.length)]; + }) + .flat(), + ], + }, + layout: {}, + }, + }); + } + } + } + } + if (layer.attributes.find((a) => a.type === "string")) { + for (const attribute of layer.attributes || []) { + if (attribute.type === "string") { + options.push({ + type: "symbol", + label: "Label from string property", + propertyChoice: { + property: attribute.attribute, + ...attribute, + }, + layer: { + type: "symbol", + layout: { + "text-field": ["get", attribute.attribute], + "text-size": 12, + }, + paint: { + "text-color": "black", + "text-halo-color": "white", + "text-halo-width": 2, + }, + }, + }); + } + } + } + return options; +} diff --git a/packages/client/src/admin/data/GLStyleEditor/formatCommand.ts b/packages/client/src/admin/data/GLStyleEditor/formatCommand.ts index 50ce5acea..00f9bf9dc 100644 --- a/packages/client/src/admin/data/GLStyleEditor/formatCommand.ts +++ b/packages/client/src/admin/data/GLStyleEditor/formatCommand.ts @@ -1,19 +1,96 @@ import { EditorView, KeyBinding } from "@codemirror/view"; import prettier from "prettier/standalone"; import babel from "prettier/parser-babel"; +import { + RawSourceMap, + SourceMapConsumer, + SourceMapGenerator, +} from "source-map"; +import parse from "json-to-ast"; +/** + * Formats the JSON in the editor using prettier and creates a source map + * so that the cursor position is preserved. + * @param view + * @returns + */ export function formatJSONCommand(view: EditorView) { - const selection = view.state.selection; try { - const parsed = JSON.parse(view.state.toJSON().doc); + const jsonString = view.state.toJSON().doc; + const originalAst = parse(jsonString); + const parsed = JSON.parse(jsonString); + const formatted = formatGLStyle(JSON.stringify(parsed)); + const formattedAst = parse(formatted); + + const sourceMapGenerator = new SourceMapGenerator({ + file: "formatted.json", + }); + + function addMappingsFromAST(originalNode: any, formattedNode: any) { + if (originalNode.loc && formattedNode.loc) { + const originalPos = originalNode.loc.start; + const formattedPos = formattedNode.loc.start; + + sourceMapGenerator.addMapping({ + source: "input.json", + original: { line: originalPos.line, column: originalPos.column }, + generated: { line: formattedPos.line, column: formattedPos.column }, + }); + } + + if ( + "value" in originalNode && + typeof originalNode.value === "object" && + "value" in formattedNode && + typeof formattedNode.value === "object" + ) { + addMappingsFromAST(originalNode.value, formattedNode.value); + } + + if ("children" in originalNode && "children" in formattedNode) { + const originalChildren = originalNode.children; + const formattedChildren = formattedNode.children; + for ( + let i = 0; + i < originalChildren.length && i < formattedChildren.length; + i++ + ) { + addMappingsFromAST(originalChildren[i], formattedChildren[i]); + } + } + } + + addMappingsFromAST(originalAst, formattedAst); + const sourceMap = sourceMapGenerator.toString(); + const sourceMapConsumer = new SourceMapConsumer( + sourceMap as unknown as RawSourceMap + ); + + const lineAt = view.state.doc.lineAt(view.state.selection.main.head); + const originalPosition = { + line: lineAt.number, + column: 1 + (view.state.selection.ranges[0].head - lineAt.from), + }; + + const newPosition = sourceMapConsumer.generatedPositionFor({ + source: "input.json", + line: originalPosition.line, + column: originalPosition.column, + }); + + view.coordsAtPos(view.state.selection.main.head); + const lines = formatted.split("\n"); + const charsToLine = lines.slice(0, newPosition.line - 1).join("\n").length; + const newSelection = charsToLine + newPosition.column; + view.dispatch( view.state.update({ changes: { from: 0, to: view.state.doc.length, - insert: formatGLStyle(JSON.stringify(parsed)), + insert: formatted, }, - selection, + selection: { anchor: newSelection }, }) ); } catch (e) {