diff --git a/README.md b/README.md index ff8059a..6548de4 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ The `MAPBOX_ACCESS_TOKEN` environment variable is required. **Each tool requires Complete set of tools for managing Mapbox styles via the Styles API: +**Style Builder Tool** - Create and modify Mapbox styles programmatically through conversational prompts + +📖 **[See the Style Builder documentation for detailed usage and examples →](./docs/STYLE_BUILDER.md)** + **ListStylesTool** - List all styles for a Mapbox account - Input: `limit` (optional - max number of styles), `start` (optional - pagination token) diff --git a/docs/STYLE_BUILDER.md b/docs/STYLE_BUILDER.md new file mode 100644 index 0000000..6c08f13 --- /dev/null +++ b/docs/STYLE_BUILDER.md @@ -0,0 +1,241 @@ +# Mapbox Style Builder Tool + +## Overview + +The Style Builder tool is a powerful utility for creating and modifying Mapbox styles programmatically. It provides a conversational interface to build complex map styles with various customizations for layers, labels, boundaries, roads, POIs, and more. + +## Important Limitations + +⚠️ **Resource Access Limitation**: Style resources (sprites, glyphs, and other assets) cannot currently be accessed through clients like Claude Desktop. This is a known limitation when using the tool through MCP (Model Context Protocol) interfaces. + +## Getting Started + +To start building a style, you can initiate the conversation with prompts like: + +- "Can you help me building a style, what customizations can I make?" +- "Create a new Mapbox style with specific features" +- "Modify my existing style to add/remove layers" + +The tool can be used for both **creating new styles** and **modifying existing styles**. + +## Example Style Creation Prompts + +### 1. Comprehensive Style with All Labels + +**Prompt**: "Create a style with all possible labels enabled, make every label have a different look so that they can be distinguished. Include also all boundaries (countries, provinces). And all roads with different colors and opacities. POIs with icons." + +This creates a maximally detailed style where: + +- Every label type has distinct visual properties +- All administrative boundaries are visible +- Roads are color-coded by type +- POIs display with appropriate icons + +### 2. Selective Administrative and Road Display + +**Prompt**: "Create a style with only administrative boundaries having admin_level 0 or 1, and roads with class motorway and oneway true" + +This creates a minimalist style focusing on: + +- Country and state/province boundaries only +- Motorways that are one-way streets +- Clean, uncluttered appearance + +### 3. Zoom-Based Road Visibility + +**Prompt**: "Create a style with minor roads only visible above zoom 14, service roads only above zoom 16, with zoom-based width increase" + +This creates a progressive detail style where: + +- Road visibility depends on zoom level +- Road widths increase smoothly with zoom +- Performance optimized for different zoom ranges + +### 4. Comprehensive POI Filtering + +**Prompt**: "Create a style with only POIs showing maki icons for restaurants, cafes, and bars, each in different colors" + +This creates a food & beverage focused style with: + +- Selective POI display +- Color-coded categories +- Clear maki icon representation + +### 5. Complex Boundary Rules + +**Prompt**: "Create a style with international boundaries (admin_level 0) that are not maritime and not disputed in solid black, disputed ones in red dashed" + +This creates a politically-aware style with: + +- Different styling for disputed boundaries +- Maritime boundary filtering +- Visual hierarchy for boundary types + +## Common Customizations + +The Style Builder supports extensive customizations including: + +### Layers + +- Add/remove specific layer types +- Modify layer ordering +- Apply filters and conditions + +### Labels + +- Control text size, font, and color +- Set visibility by zoom level +- Adjust label density and overlap behavior + +### Roads + +- Customize by road class (motorway, trunk, primary, secondary, etc.) +- Apply different styles for bridges and tunnels +- Control casing and width properties + +### Boundaries + +- Filter by administrative level +- Style disputed boundaries differently +- Control maritime boundary display + +### POIs (Points of Interest) + +- Filter by category or specific types +- Customize icons and colors +- Control density and zoom-based visibility + +### Buildings + +- 3D extrusion settings +- Color by height or type +- Opacity and visibility controls + +### Terrain and Hillshading + +- Add terrain layers +- Adjust hillshade intensity +- Control exaggeration factors + +## Advanced Features + +### Working with Existing Styles + +The tool can modify existing Mapbox styles: + +- Import a style by ID or URL +- Make targeted modifications +- Preserve existing customizations while adding new features + +### Performance Optimization + +The builder can optimize styles for: + +- Mobile devices (reduced layer count) +- High-density displays +- Specific zoom ranges + +### Theme Variations + +Create multiple versions of a style: + +- Light and dark modes +- Seasonal variations +- Brand-specific color schemes + +## Best Practices + +1. **Start Simple**: Begin with basic requirements and iteratively add complexity +2. **Test at Multiple Zooms**: Ensure your style works well across zoom levels +3. **Consider Performance**: More layers and complex filters can impact rendering speed +4. **Use Consistent Naming**: When creating custom layers, use clear, descriptive IDs +5. **Document Your Choices**: Keep notes on why certain styling decisions were made + +## Troubleshooting + +### Common Issues + +1. **Resources Not Loading**: Remember that sprite and glyph resources may not be accessible in Claude Desktop +2. **Layer Conflicts**: Check layer ordering if elements appear hidden +3. **Performance Issues**: Reduce layer count or simplify filters for better performance +4. **Zoom Range Problems**: Verify minzoom and maxzoom settings on layers + +### Getting Help + +When encountering issues, provide: + +- The style configuration you're trying to achieve +- Any error messages received +- The platform/client you're using + +## Technical Details + +The Style Builder tool: + +- Generates Mapbox GL JS compatible style specifications +- Follows the Mapbox Style Specification v8 +- Supports all standard Mapbox layer types +- Can output styles for use in Mapbox GL JS, native SDKs, and Mapbox Studio + +## Limitations and Considerations + +- Some advanced Studio-only features may not be available +- Custom data sources need to be added separately +- Sprite and font resources must be hosted and accessible +- Complex expressions may need manual refinement + +## Integration with Other Tools + +Once you've built or modified a style using the Style Builder: + +### Creating a New Style + +Use the **CreateStyleTool** to save your generated style to your Mapbox account: + +- The tool will create a new style with your specifications +- Returns a style ID that you can use for further modifications + +### Updating an Existing Style + +Use the **UpdateStyleTool** to apply modifications to an existing style: + +- Provide the style ID or name of the style you want to update (if the name uniquely identifies it) +- The tool will update the style with your new specifications + +### Previewing Your Style + +Use the **PreviewStyleTool** to generate a preview URL: + +- Instantly view your style in a browser +- Test different zoom levels and locations +- Share the preview link with team members + +**Example workflow for new style:** + +1. "Build a style with only roads and labels" +2. "Now create this style in my account" → Uses CreateStyleTool +3. "Generate a preview link for this style" → Uses PreviewStyleTool + +**Example workflow for modifying existing style:** + +1. "Modify my 'Winter Theme' style to add POIs with restaurant icons" +2. "Update the style in my account" → Uses UpdateStyleTool (finds style by name) +3. "Generate a preview link for this style" → Uses PreviewStyleTool + +**Alternative with style ID:** + +1. "Modify style clxyz123... to add building extrusions" +2. "Update the style in my account" → Uses UpdateStyleTool (uses style ID) +3. "Generate a preview link for this style" → Uses PreviewStyleTool + +## Next Steps + +After creating or modifying your style: + +1. Test in your target environment using the preview URL +2. Use the style in your applications with the style ID +3. Optimize for your specific use case +4. Consider creating variations for different contexts +5. Your styles are also viewable and editable in Mapbox Studio if needed + +For more information on Mapbox styles, refer to the [Mapbox Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/). diff --git a/docs/style-resource-example.md b/docs/style-resource-example.md new file mode 100644 index 0000000..0035a3c --- /dev/null +++ b/docs/style-resource-example.md @@ -0,0 +1,144 @@ +# Mapbox Style Layers Resource Example + +This example demonstrates how the Mapbox Style Layers Resource guides LLMs in creating Mapbox styles based on natural language requests. + +## How It Works + +1. The `MapboxStyleLayersResource` is registered as an MCP resource at `resource://mapbox-style-layers` +2. When an LLM receives a request like "create a style that highlights railways and parks, and changes water to yellow", it can: + - Query the resource to understand available layers + - Get the correct source-layer names, filters, and properties + - Generate proper Mapbox GL style JSON + +## Example User Requests + +### Request 1: "Highlight railways and parks, make water yellow" + +The resource guides the LLM to: + +- Use `water` layer (source-layer: "water") with `fill-color: "#ffff00"` +- Use `landuse` layer filtered by `class: "park"` with bright green color +- Use `road` layer filtered by `class: ["major_rail", "minor_rail"]` with red color + +### Request 2: "Create a dark mode map with prominent buildings" + +The resource guides the LLM to: + +- Set `land` background layer to dark color +- Use `building` layer with light color and high opacity +- Adjust text colors for visibility on dark background + +### Request 3: "Show only major roads and hide all labels" + +The resource guides the LLM to: + +- Include only `motorways` and `primary_roads` layers +- Exclude all label layers (`place_labels`, `road_labels`, `poi_labels`) +- Use appropriate filters like `["match", ["get", "class"], ["motorway", "trunk"], true, false]` + +## Resource Content Structure + +The resource provides: + +1. **Quick Reference**: Maps common user requests to specific layers +2. **Layer Categories**: Groups layers by type (water, transportation, labels, etc.) +3. **Detailed Specifications**: For each layer: + - Description + - Source layer name + - Layer type (fill, line, symbol, etc.) + - Common filters + - Paint properties with examples + - Layout properties with examples + - Example user requests + +4. **Expression Examples**: Common Mapbox GL expression patterns: + - Zoom-based interpolation + - Feature property matching + - Conditional styling + +## Usage in LLM Context + +When the LLM needs to create or modify a Mapbox style: + +1. It reads the resource to understand available layers +2. Maps user intent to specific layers using the descriptions and examples +3. Generates proper filter expressions using the provided patterns +4. Creates valid paint and layout properties + +## Example Generated Style + +Based on "highlight railways and parks, yellow water": + +```json +{ + "version": 8, + "name": "Custom Style", + "sources": { + "composite": { + "type": "vector", + "url": "mapbox://mapbox.mapbox-streets-v8" + } + }, + "layers": [ + { + "id": "land", + "type": "background", + "paint": { + "background-color": "#f8f4f0" + } + }, + { + "id": "water", + "type": "fill", + "source": "composite", + "source-layer": "water", + "paint": { + "fill-color": "#ffff00" + } + }, + { + "id": "parks", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": ["==", ["get", "class"], "park"], + "paint": { + "fill-color": "#00ff00", + "fill-opacity": 0.9 + } + }, + { + "id": "railways", + "type": "line", + "source": "composite", + "source-layer": "road", + "filter": [ + "match", + ["get", "class"], + ["major_rail", "minor_rail"], + true, + false + ], + "paint": { + "line-color": "#ff0000", + "line-width": [ + "interpolate", + ["exponential", 1.5], + ["zoom"], + 14, + 2, + 20, + 8 + ] + } + } + ] +} +``` + +## Benefits + +1. **Accuracy**: LLM uses correct source-layer names and properties +2. **Completeness**: All necessary filter expressions are included +3. **Best Practices**: Follows Mapbox GL style specification patterns +4. **Natural Language**: Users can describe what they want without knowing technical details diff --git a/src/constants/mapboxStreetsV8Fields.ts b/src/constants/mapboxStreetsV8Fields.ts new file mode 100644 index 0000000..16201a8 --- /dev/null +++ b/src/constants/mapboxStreetsV8Fields.ts @@ -0,0 +1,312 @@ +/** + * Complete field definitions for Mapbox Streets v8 source layers + * This provides all available properties for filtering + */ + +export const STREETS_V8_FIELDS = { + road: { + class: { + description: 'Road classification', + values: [ + 'motorway', + 'motorway_link', + 'trunk', + 'trunk_link', + 'primary', + 'primary_link', + 'secondary', + 'secondary_link', + 'tertiary', + 'tertiary_link', + 'street', + 'street_limited', + 'pedestrian', + 'construction', + 'track', + 'service', + 'ferry', + 'path', + 'golf', + 'level_crossing', + 'turning_circle', + 'roundabout', + 'mini_roundabout', + 'turning_loop', + 'traffic_signals', + 'major_rail', + 'minor_rail', + 'service_rail', + 'aerialway' + ] as const + }, + structure: { + description: 'Physical structure', + values: ['none', 'bridge', 'tunnel', 'ford'] as const + }, + type: { + description: 'Specific road type from OSM tags', + values: [ + 'steps', + 'corridor', + 'parking_aisle', + 'platform', + 'piste' + ] as const + }, + oneway: { + description: 'One-way traffic', + values: ['true', 'false'] as const + }, + dual_carriageway: { + description: 'Part of dual carriageway', + values: ['true', 'false'] as const + }, + surface: { + description: 'Road surface', + values: ['paved', 'unpaved'] as const + }, + toll: { + description: 'Toll road', + values: ['true', 'false'] as const + }, + layer: { + description: 'Z-ordering layer (-5 to 5)', + type: 'number' as const + }, + lane_count: { + description: 'Number of lanes', + type: 'number' as const + } + }, + + admin: { + admin_level: { + description: 'Administrative level', + values: [0, 1, 2] as const // 0=country, 1=state/province, 2=county + }, + disputed: { + description: 'Disputed boundary', + values: ['true', 'false'] as const + }, + maritime: { + description: 'Maritime boundary', + values: ['true', 'false'] as const + }, + worldview: { + description: 'Worldview perspective', + values: [ + 'all', + 'CN', + 'IN', + 'US', + 'JP', + 'AR', + 'MA', + 'RS', + 'RU', + 'TR', + 'VN' + ] as const + } + }, + + landuse: { + class: { + description: 'Landuse classification', + values: [ + 'aboriginal_lands', + 'agriculture', + 'airport', + 'cemetery', + 'commercial_area', + 'facility', + 'glacier', + 'grass', + 'hospital', + 'industrial', + 'park', + 'parking', + 'piste', + 'pitch', + 'residential', + 'rock', + 'sand', + 'school', + 'scrub', + 'wood' + ] as const + } + }, + + landuse_overlay: { + class: { + description: 'Overlay classification', + values: ['national_park', 'wetland', 'wetland_noveg'] as const + } + }, + + building: { + extrude: { + description: 'Should be extruded in 3D', + values: ['true', 'false'] as const + }, + underground: { + description: 'Underground building', + values: ['true', 'false'] as const + }, + height: { + description: 'Building height', + type: 'number' as const + }, + min_height: { + description: 'Building base height', + type: 'number' as const + } + }, + + water: { + // Water has no filterable fields + }, + + waterway: { + class: { + description: 'Waterway classification', + values: [ + 'river', + 'canal', + 'stream', + 'stream_intermittent', + 'ditch', + 'drain' + ] as const + }, + type: { + description: 'Waterway type', + values: ['river', 'canal', 'stream', 'ditch', 'drain'] as const + } + }, + + aeroway: { + type: { + description: 'Aeroway type', + values: ['runway', 'taxiway', 'apron', 'helipad'] as const + } + }, + + place_label: { + class: { + description: 'Place classification', + values: [ + 'country', + 'state', + 'settlement', + 'settlement_subdivision' + ] as const + }, + capital: { + description: 'Capital admin level', + values: [2, 3, 4, 5, 6] as const + }, + filterrank: { + description: 'Priority for label density', + type: 'number' as const // 0-5 + }, + symbolrank: { + description: 'Symbol ranking', + type: 'number' as const + } + }, + + poi_label: { + class: { + description: 'POI thematic grouping', + type: 'string' as const + }, + filterrank: { + description: 'Priority for label density', + type: 'number' as const // 0-5 + }, + maki: { + description: 'Icon to use (e.g., airport, hospital, restaurant, park)', + type: 'string' as const + } + }, + + natural_label: { + class: { + description: 'Natural feature classification', + values: [ + 'glacier', + 'landform', + 'water_feature', + 'wetland', + 'ocean', + 'sea', + 'river', + 'water', + 'reservoir', + 'dock', + 'canal', + 'drain', + 'ditch', + 'stream', + 'continent' + ] as const + }, + elevation_m: { + description: 'Elevation in meters', + type: 'number' as const + } + }, + + transit_stop_label: { + mode: { + description: 'Transit mode', + values: [ + 'rail', + 'metro_rail', + 'light_rail', + 'tram', + 'bus', + 'monorail', + 'funicular', + 'bicycle', + 'ferry', + 'narrow_gauge', + 'preserved', + 'miniature' + ] as const + }, + maki: { + description: 'Icon type (visual representation of transit type)', + values: [ + 'rail', + 'rail-metro', + 'rail-light', + 'entrance', + 'bus', + 'bicycle-share', + 'ferry' + ] as const + } + }, + + airport_label: { + class: { + description: 'Airport classification', + values: ['military', 'civil'] as const + }, + maki: { + description: 'Icon type (visual representation)', + values: ['airport', 'heliport', 'rocket'] as const + } + } +} as const; + +export type SourceLayer = keyof typeof STREETS_V8_FIELDS; +export type FieldValues< + L extends SourceLayer, + F extends keyof (typeof STREETS_V8_FIELDS)[L] +> = (typeof STREETS_V8_FIELDS)[L][F] extends { values: readonly any[] } + ? (typeof STREETS_V8_FIELDS)[L][F]['values'][number] + : any; diff --git a/src/constants/mapboxStyleLayers.ts b/src/constants/mapboxStyleLayers.ts new file mode 100644 index 0000000..655adb7 --- /dev/null +++ b/src/constants/mapboxStyleLayers.ts @@ -0,0 +1,784 @@ +/** + * Mapbox Style Layer Definitions + * + * Comprehensive descriptions of all Mapbox style layers to guide LLMs in creating styles. + * Based on Mapbox Streets v12 specification. + */ + +export interface LayerDefinition { + id: string; + description: string; + sourceLayer?: string; + type: + | 'background' + | 'fill' + | 'line' + | 'symbol' + | 'circle' + | 'raster' + | 'hillshade' + | 'heatmap' + | 'fill-extrusion' + | 'sky'; + commonFilters?: string[]; + availableProperties?: Record< + string, + { + description: string; + values?: string[]; + type?: 'string' | 'number' | 'boolean'; + } + >; + paintProperties: { + property: string; + description: string; + example: unknown; + }[]; + layoutProperties?: { + property: string; + description: string; + example: unknown; + }[]; + examples: string[]; +} + +export const MAPBOX_STYLE_LAYERS: Record = { + // Background layers + land: { + id: 'land', + description: + 'Background layer for land/terrain. Sets the base color of the map.', + type: 'background', + paintProperties: [ + { + property: 'background-color', + description: 'Color of the land/background', + example: '#f8f4f0' + } + ], + examples: [ + 'Create a dark mode map with black land', + 'Make the background beige' + ] + }, + + // Water features + water: { + id: 'water', + description: 'Fill layer for water bodies like oceans, lakes, and rivers', + sourceLayer: 'water', + type: 'fill', + paintProperties: [ + { + property: 'fill-color', + description: 'Color of water bodies', + example: '#73b6e6' + }, + { + property: 'fill-opacity', + description: 'Opacity of water (0-1)', + example: 1 + } + ], + examples: [ + 'Change water to yellow', + 'Make oceans dark blue', + 'Set lakes to turquoise' + ] + }, + + waterway: { + id: 'waterway', + description: 'Line layer for rivers, streams, and canals', + sourceLayer: 'waterway', + type: 'line', + commonFilters: ['class: river, stream, canal'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of waterways', + example: '#73b6e6' + }, + { + property: 'line-width', + description: 'Width of waterway lines', + example: ['interpolate', ['exponential', 1.3], ['zoom'], 8, 0.5, 20, 6] + } + ], + examples: [ + 'Highlight rivers in bright blue', + 'Make streams wider', + 'Show canals in green' + ] + }, + + // Landuse and land cover + parks: { + id: 'landuse_park', + description: 'Fill layer for parks, gardens, and green spaces', + sourceLayer: 'landuse', + type: 'fill', + commonFilters: ['class: park, cemetery, golf_course'], + paintProperties: [ + { + property: 'fill-color', + description: 'Color of parks and green spaces', + example: '#d8e8c8' + }, + { + property: 'fill-opacity', + description: 'Opacity of parks', + example: 0.9 + } + ], + examples: [ + 'Highlight parks in bright green', + 'Make parks darker', + 'Show golf courses in different shade' + ] + }, + + buildings: { + id: 'building', + description: 'Fill or fill-extrusion layer for buildings', + sourceLayer: 'building', + type: 'fill', + paintProperties: [ + { + property: 'fill-color', + description: 'Color of buildings', + example: '#e0d8ce' + }, + { + property: 'fill-opacity', + description: 'Opacity of buildings', + example: ['interpolate', ['linear'], ['zoom'], 15, 0, 16, 1] + } + ], + examples: [ + 'Show buildings in red', + 'Make buildings semi-transparent', + 'Hide buildings at low zoom' + ] + }, + + building_3d: { + id: 'building-3d', + description: '3D extrusion layer for buildings', + sourceLayer: 'building', + type: 'fill-extrusion', + paintProperties: [ + { + property: 'fill-extrusion-color', + description: 'Color of 3D buildings', + example: '#e0d8ce' + }, + { + property: 'fill-extrusion-height', + description: 'Height of buildings', + example: ['get', 'height'] + }, + { + property: 'fill-extrusion-base', + description: 'Base height of buildings', + example: ['get', 'min_height'] + } + ], + examples: [ + 'Create 3D buildings', + 'Make buildings taller', + 'Color buildings by height' + ] + }, + + // Transportation + railways: { + id: 'road-rail', + description: 'Line layer for railway tracks and rail lines', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: major_rail, minor_rail, service_rail'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of railway lines', + example: '#bbb' + }, + { + property: 'line-width', + description: 'Width of railway lines', + example: ['interpolate', ['exponential', 1.5], ['zoom'], 14, 0.5, 20, 2] + } + ], + layoutProperties: [ + { + property: 'line-join', + description: 'Line join style', + example: 'round' + } + ], + examples: [ + 'Highlight railways in red', + 'Make train tracks thicker', + 'Show metro lines differently' + ] + }, + + motorways: { + id: 'road-motorway', + description: 'Line layer for highways and motorways', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: motorway, trunk'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of highways', + example: '#fc8' + }, + { + property: 'line-width', + description: 'Width of highway lines', + example: ['interpolate', ['exponential', 1.5], ['zoom'], 5, 0.5, 18, 30] + } + ], + examples: [ + 'Make highways orange', + 'Widen motorways', + 'Highlight major roads' + ] + }, + + primary_roads: { + id: 'road-primary', + description: 'Line layer for primary/main roads', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: primary'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of primary roads', + example: '#fea' + }, + { + property: 'line-width', + description: 'Width of primary roads', + example: ['interpolate', ['exponential', 1.5], ['zoom'], 5, 0.5, 18, 26] + } + ], + examples: ['Color main roads yellow', 'Make primary roads prominent'] + }, + + secondary_roads: { + id: 'road-secondary', + description: 'Line layer for secondary roads', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: secondary, tertiary'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of secondary roads', + example: '#fff' + }, + { + property: 'line-width', + description: 'Width of secondary roads', + example: [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], + 11, + 0.5, + 18, + 20 + ] + } + ], + examples: ['Show secondary roads in gray', 'Make minor roads thinner'] + }, + + streets: { + id: 'road-street', + description: 'Line layer for local streets', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: street, street_limited, residential, service'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of streets', + example: '#fff' + }, + { + property: 'line-width', + description: 'Width of streets', + example: [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], + 12, + 0.5, + 18, + 12 + ] + } + ], + examples: [ + 'Color residential streets', + 'Hide small streets', + 'Make local roads visible' + ] + }, + + paths: { + id: 'road-path', + description: 'Line layer for pedestrian paths, footways, and trails', + sourceLayer: 'road', + type: 'line', + commonFilters: ['class: path, pedestrian'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of paths', + example: '#cba' + }, + { + property: 'line-width', + description: 'Width of paths', + example: ['interpolate', ['exponential', 1.5], ['zoom'], 15, 1, 18, 4] + }, + { + property: 'line-dasharray', + description: 'Dash pattern for paths', + example: [1, 1] + } + ], + examples: [ + 'Show walking paths as dotted lines', + 'Highlight hiking trails', + 'Color bike paths green' + ] + }, + + tunnels: { + id: 'tunnel', + description: 'Line layers for roads in tunnels (with special styling)', + sourceLayer: 'road', + type: 'line', + commonFilters: ['structure: tunnel'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of tunnel roads', + example: '#fff' + }, + { + property: 'line-opacity', + description: 'Opacity of tunnel roads (usually reduced)', + example: 0.5 + }, + { + property: 'line-dasharray', + description: 'Dash pattern for tunnels', + example: [0.4, 0.4] + } + ], + examples: ['Make tunnels semi-transparent', 'Show tunnels as dashed lines'] + }, + + bridges: { + id: 'bridge', + description: 'Line layers for roads on bridges (with special casing)', + sourceLayer: 'road', + type: 'line', + commonFilters: ['structure: bridge'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of bridge roads', + example: '#fff' + }, + { + property: 'line-width', + description: 'Width of bridges (usually wider than regular roads)', + example: ['interpolate', ['exponential', 1.5], ['zoom'], 12, 1, 18, 30] + } + ], + examples: [ + 'Highlight bridges', + 'Make bridge outlines thicker', + 'Color bridges differently' + ] + }, + + airports: { + id: 'aeroway', + description: 'Fill and line layers for airport runways and taxiways', + sourceLayer: 'aeroway', + type: 'fill', + paintProperties: [ + { + property: 'fill-color', + description: 'Color of airport areas', + example: '#ddd' + }, + { + property: 'fill-opacity', + description: 'Opacity of airport areas', + example: 1 + } + ], + examples: [ + 'Show airports in gray', + 'Highlight runways', + 'Make airport areas visible' + ] + }, + + // Administrative boundaries + country_boundaries: { + id: 'admin-0-boundary', + description: + 'Line layer for country/nation boundaries (from admin source-layer)', + sourceLayer: 'admin', + type: 'line', + commonFilters: ['admin_level: 0', 'maritime: false', 'disputed: false'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of country borders', + example: '#8b8aba' + }, + { + property: 'line-width', + description: 'Width of country borders', + example: ['interpolate', ['linear'], ['zoom'], 3, 0.5, 10, 2] + }, + { + property: 'line-dasharray', + description: 'Dash pattern for disputed borders', + example: [2, 2] + } + ], + examples: [ + 'Make country borders red', + 'Show disputed boundaries as dashed', + 'Thicken international borders' + ] + }, + + state_boundaries: { + id: 'admin-1-boundary', + description: + 'Line layer for state/province boundaries (from admin source-layer)', + sourceLayer: 'admin', + type: 'line', + commonFilters: ['admin_level: 1', 'maritime: false'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of state borders', + example: '#9e9cab' + }, + { + property: 'line-width', + description: 'Width of state borders', + example: ['interpolate', ['linear'], ['zoom'], 3, 0.3, 10, 1.5] + } + ], + examples: [ + 'Show state boundaries', + 'Make province borders visible', + 'Color regional boundaries' + ] + }, + + disputed_boundaries: { + id: 'admin-disputed', + description: 'Line layer for disputed boundaries', + sourceLayer: 'admin', + type: 'line', + commonFilters: ['disputed: 1'], + paintProperties: [ + { + property: 'line-color', + description: 'Color of disputed borders', + example: '#ff0000' + }, + { + property: 'line-width', + description: 'Width of disputed borders', + example: 2 + }, + { + property: 'line-dasharray', + description: 'Dash pattern for disputed borders', + example: [2, 4] + } + ], + examples: ['Show disputed territories', 'Highlight contested borders'] + }, + + // Labels + place_labels: { + id: 'place-label', + description: 'Symbol layer for city, town, and place name labels', + sourceLayer: 'place_label', + type: 'symbol', + commonFilters: ['class: settlement, city, town, village'], + layoutProperties: [ + { + property: 'text-field', + description: 'Text to display', + example: ['get', 'name'] + }, + { + property: 'text-font', + description: 'Font family', + example: ['DIN Pro Medium', 'Arial Unicode MS Regular'] + }, + { + property: 'text-size', + description: 'Text size', + example: ['interpolate', ['linear'], ['zoom'], 10, 12, 18, 24] + } + ], + paintProperties: [ + { + property: 'text-color', + description: 'Color of place labels', + example: '#333' + }, + { + property: 'text-halo-color', + description: 'Color of text halo/outline', + example: '#fff' + }, + { + property: 'text-halo-width', + description: 'Width of text halo', + example: 1.5 + } + ], + examples: [ + 'Hide city names', + 'Make town labels larger', + 'Color place names blue' + ] + }, + + road_labels: { + id: 'road-label', + description: 'Symbol layer for road name labels', + sourceLayer: 'road', + type: 'symbol', + layoutProperties: [ + { + property: 'symbol-placement', + description: 'Label placement strategy', + example: 'line' + }, + { + property: 'text-field', + description: 'Road name text', + example: ['get', 'name'] + }, + { + property: 'text-font', + description: 'Font for road names', + example: ['DIN Pro Regular', 'Arial Unicode MS Regular'] + }, + { + property: 'text-size', + description: 'Size of road labels', + example: 12 + }, + { + property: 'text-rotation-alignment', + description: 'Text rotation alignment', + example: 'map' + } + ], + paintProperties: [ + { + property: 'text-color', + description: 'Color of road labels', + example: '#666' + }, + { + property: 'text-halo-color', + description: 'Halo color for road labels', + example: '#fff' + } + ], + examples: [ + 'Show street names', + 'Hide road labels', + 'Make road names bigger' + ] + }, + + poi_labels: { + id: 'poi-label', + description: 'Symbol layer for points of interest (POI) labels', + sourceLayer: 'poi_label', + type: 'symbol', + commonFilters: ['class: park, hospital, school, museum, etc.'], + layoutProperties: [ + { + property: 'text-field', + description: 'POI name', + example: ['get', 'name'] + }, + { + property: 'icon-image', + description: 'Icon for POI', + example: ['get', 'maki'] + }, + { + property: 'text-anchor', + description: 'Text anchor position', + example: 'top' + } + ], + paintProperties: [ + { + property: 'text-color', + description: 'Color of POI labels', + example: '#666' + }, + { + property: 'icon-opacity', + description: 'Opacity of POI icons', + example: 1 + } + ], + examples: [ + 'Show restaurant names', + 'Hide POI labels', + 'Display park names in green' + ] + }, + + transit: { + id: 'transit', + description: 'Symbol layer for transit stations and stops', + sourceLayer: 'transit_stop_label', + type: 'symbol', + layoutProperties: [ + { + property: 'text-field', + description: 'Station name', + example: ['get', 'name'] + }, + { + property: 'icon-image', + description: 'Transit icon', + example: ['get', 'network'] + } + ], + paintProperties: [ + { + property: 'text-color', + description: 'Color of transit labels', + example: '#4898ff' + } + ], + examples: [ + 'Show subway stations', + 'Highlight bus stops', + 'Display train stations prominently' + ] + } +}; + +// Helper function to get layer suggestions based on user input +export function getLayerSuggestions(userPrompt: string): string[] { + const prompt = userPrompt.toLowerCase(); + const suggestions: string[] = []; + + Object.entries(MAPBOX_STYLE_LAYERS).forEach(([key, layer]) => { + // Check if the prompt mentions this layer type + const keywords = [ + key, + layer.id, + layer.sourceLayer, + ...layer.examples.join(' ').toLowerCase().split(' ') + ].filter(Boolean); + + if (keywords.some((keyword) => prompt.includes(keyword as string))) { + suggestions.push(key); + } + }); + + // Add specific keyword mappings + if ( + prompt.includes('water') || + prompt.includes('ocean') || + prompt.includes('sea') || + prompt.includes('lake') + ) { + suggestions.push('water', 'waterway'); + } + if ( + prompt.includes('park') || + prompt.includes('green') || + prompt.includes('garden') + ) { + suggestions.push('parks'); + } + if ( + prompt.includes('railway') || + prompt.includes('train') || + prompt.includes('rail') || + prompt.includes('metro') + ) { + suggestions.push('railways'); + } + if ( + prompt.includes('road') || + prompt.includes('street') || + prompt.includes('highway') || + prompt.includes('motorway') + ) { + suggestions.push( + 'motorways', + 'primary_roads', + 'secondary_roads', + 'streets' + ); + } + if ( + prompt.includes('building') || + prompt.includes('house') || + prompt.includes('3d') + ) { + suggestions.push('buildings', 'building_3d'); + } + if ( + prompt.includes('label') || + prompt.includes('name') || + prompt.includes('text') + ) { + suggestions.push('place_labels', 'road_labels', 'poi_labels'); + } + if ( + prompt.includes('country') || + prompt.includes('border') || + prompt.includes('boundary') + ) { + suggestions.push('country_boundaries', 'state_boundaries'); + } + if ( + prompt.includes('transit') || + prompt.includes('subway') || + prompt.includes('bus') || + prompt.includes('station') + ) { + suggestions.push('transit'); + } + + return [...new Set(suggestions)]; +} diff --git a/src/index.ts b/src/index.ts index 6e5e6c2..d9d84b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js'; import { getAllTools } from './tools/toolRegistry.js'; +import { getAllResources } from './resources/resourceRegistry.js'; import { getVersionInfo } from './utils/versionUtils.js'; // Get version info and patch fetch @@ -23,7 +24,8 @@ const server = new McpServer( { capabilities: { logging: {}, - tools: {} + tools: {}, + resources: {} } } ); @@ -33,6 +35,12 @@ enabledTools.forEach((tool) => { tool.installTo(server); }); +// Register resources to the server +const resources = getAllResources(); +resources.forEach((resource) => { + resource.installTo(server); +}); + async function main() { // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); diff --git a/src/resources/BaseResource.ts b/src/resources/BaseResource.ts new file mode 100644 index 0000000..cf4c21f --- /dev/null +++ b/src/resources/BaseResource.ts @@ -0,0 +1,36 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +/** + * Base class for MCP resources + */ +export abstract class BaseResource { + abstract readonly name: string; + abstract readonly uri: string; + abstract readonly description: string; + abstract readonly mimeType: string; + + /** + * Install this resource to the MCP server + */ + installTo(server: McpServer): void { + server.resource( + this.name, + this.uri, + { + description: this.description, + mimeType: this.mimeType + }, + this.readCallback.bind(this) + ); + } + + /** + * Callback to read the resource content + */ + protected abstract readCallback( + uri: URL, + extra: unknown + ): Promise<{ + contents: Array<{ uri: string; mimeType: string; text: string }>; + }>; +} diff --git a/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts b/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts new file mode 100644 index 0000000..b7d0e61 --- /dev/null +++ b/src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.ts @@ -0,0 +1,327 @@ +import { BaseResource } from '../BaseResource.js'; +import { + MAPBOX_STYLE_LAYERS, + getLayerSuggestions +} from '../../constants/mapboxStyleLayers.js'; + +/** + * Resource providing comprehensive Mapbox style layer definitions + * to guide LLMs in creating and modifying Mapbox styles + */ +export class MapboxStyleLayersResource extends BaseResource { + readonly name = 'Mapbox Style Layers Guide'; + readonly uri = 'resource://mapbox-style-layers'; + readonly description = + 'Comprehensive guide for Mapbox style layers including types, properties, and examples'; + readonly mimeType = 'text/markdown'; + + protected async readCallback(uri: URL) { + // Generate comprehensive markdown documentation + const markdown = this.generateMarkdown(); + + return { + contents: [ + { + uri: uri.href, + mimeType: this.mimeType, + text: markdown + } + ] + }; + } + + private generateMarkdown(): string { + const sections: string[] = []; + + // Header + sections.push('# Mapbox Style Creation Guide'); + sections.push(''); + sections.push('## How to Create a Custom Mapbox Style'); + sections.push(''); + sections.push('### Step-by-Step Process:'); + sections.push( + '1. **Understand the request** - What layers should be visible? What colors/styling?' + ); + sections.push( + '2. **Use style_builder_tool** - This tool generates the style JSON configuration' + ); + sections.push( + '3. **Apply the style** - Use create_style_tool to create a new style or update_style_tool to modify existing' + ); + sections.push(''); + sections.push('### Example Workflow:'); + sections.push('```'); + sections.push( + 'User: "Create a dark mode style with blue water and hidden labels"' + ); + sections.push('Assistant: '); + sections.push('1. Uses style_builder_tool with:'); + sections.push(' - global_settings: { mode: "dark" }'); + sections.push(' - layers: ['); + sections.push( + ' { layer_type: "water", action: "color", color: "#0066ff" },' + ); + sections.push(' { layer_type: "place_labels", action: "hide" },'); + sections.push(' { layer_type: "road_labels", action: "hide" }'); + sections.push(' ]'); + sections.push('2. Uses create_style_tool with the generated JSON'); + sections.push('```'); + sections.push(''); + sections.push('## Quick Reference'); + sections.push(''); + sections.push('### Common User Requests → Layer Mappings'); + sections.push(''); + sections.push('- **"change water color"** → `water`, `waterway`'); + sections.push( + '- **"highlight parks"** → `parks` (landuse with class=park)' + ); + sections.push( + '- **"show railways"** → `railways` (road with class=major_rail)' + ); + sections.push( + '- **"color roads"** → `motorways`, `primary_roads`, `secondary_roads`, `streets`' + ); + sections.push('- **"3D buildings"** → `building_3d` (fill-extrusion)'); + sections.push( + '- **"hide labels"** → `place_labels`, `road_labels`, `poi_labels`' + ); + sections.push( + '- **"show borders"** → `country_boundaries`, `state_boundaries`' + ); + sections.push('- **"transit/subway"** → `transit`, `railways`'); + sections.push( + '- **"country boundaries"** → `country_boundaries` (admin layer, admin_level=0)' + ); + sections.push( + '- **"state boundaries"** → `state_boundaries` (admin layer, admin_level=1)' + ); + sections.push(''); + + // Layer categories + sections.push('## Layer Categories'); + sections.push(''); + + const categories = { + 'Background & Base': ['land'], + 'Water Features': ['water', 'waterway'], + 'Land Use': ['parks', 'buildings', 'building_3d'], + Transportation: [ + 'railways', + 'motorways', + 'primary_roads', + 'secondary_roads', + 'streets', + 'paths', + 'tunnels', + 'bridges' + ], + Aviation: ['airports'], + Boundaries: ['country_boundaries', 'state_boundaries'], + Labels: ['place_labels', 'road_labels', 'poi_labels', 'transit'] + }; + + Object.entries(categories).forEach(([category, layers]) => { + sections.push(`### ${category}`); + layers.forEach((layerKey) => { + const layer = MAPBOX_STYLE_LAYERS[layerKey]; + if (layer) { + sections.push(`- **${layerKey}**: ${layer.description}`); + } + }); + sections.push(''); + }); + + // Detailed layer specifications + sections.push('## Detailed Layer Specifications'); + sections.push(''); + + Object.entries(MAPBOX_STYLE_LAYERS).forEach(([key, layer]) => { + sections.push(`### ${key}`); + sections.push(''); + sections.push(`**Description:** ${layer.description}`); + sections.push(''); + + if (layer.sourceLayer) { + sections.push(`**Source Layer:** \`${layer.sourceLayer}\``); + sections.push(''); + } + + sections.push(`**Type:** \`${layer.type}\``); + sections.push(''); + + if (layer.commonFilters && layer.commonFilters.length > 0) { + sections.push('**Common Filters:**'); + layer.commonFilters.forEach((filter) => { + sections.push(`- ${filter}`); + }); + sections.push(''); + } + + if (layer.paintProperties.length > 0) { + sections.push('**Paint Properties:**'); + sections.push(''); + layer.paintProperties.forEach((prop) => { + sections.push(`- \`${prop.property}\`: ${prop.description}`); + sections.push(` - Example: \`${JSON.stringify(prop.example)}\``); + }); + sections.push(''); + } + + if (layer.layoutProperties && layer.layoutProperties.length > 0) { + sections.push('**Layout Properties:**'); + sections.push(''); + layer.layoutProperties.forEach((prop) => { + sections.push(`- \`${prop.property}\`: ${prop.description}`); + sections.push(` - Example: \`${JSON.stringify(prop.example)}\``); + }); + sections.push(''); + } + + if (layer.examples.length > 0) { + sections.push('**Example User Requests:**'); + layer.examples.forEach((example) => { + sections.push(`- "${example}"`); + }); + sections.push(''); + } + + sections.push('---'); + sections.push(''); + }); + + // Usage examples + sections.push('## Complete Style Examples'); + sections.push(''); + sections.push('### Example 1: Highlight Railways and Parks, Yellow Water'); + sections.push(''); + sections.push('```javascript'); + sections.push('layers: ['); + sections.push(' {'); + sections.push(' id: "water",'); + sections.push(' type: "fill",'); + sections.push(' source: "composite",'); + sections.push(' "source-layer": "water",'); + sections.push(' paint: {'); + sections.push(' "fill-color": "#ffff00" // Yellow'); + sections.push(' }'); + sections.push(' },'); + sections.push(' {'); + sections.push(' id: "parks",'); + sections.push(' type: "fill",'); + sections.push(' source: "composite",'); + sections.push(' "source-layer": "landuse",'); + sections.push(' filter: ["==", ["get", "class"], "park"],'); + sections.push(' paint: {'); + sections.push(' "fill-color": "#00ff00", // Bright green'); + sections.push(' "fill-opacity": 0.9'); + sections.push(' }'); + sections.push(' },'); + sections.push(' {'); + sections.push(' id: "railways",'); + sections.push(' type: "line",'); + sections.push(' source: "composite",'); + sections.push(' "source-layer": "road",'); + sections.push( + ' filter: ["match", ["get", "class"], ["major_rail", "minor_rail"], true, false],' + ); + sections.push(' paint: {'); + sections.push(' "line-color": "#ff0000", // Red'); + sections.push( + ' "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 14, 2, 20, 8]' + ); + sections.push(' }'); + sections.push(' }'); + sections.push(']'); + sections.push('```'); + sections.push(''); + + // Expression examples + sections.push('## Common Expression Patterns'); + sections.push(''); + sections.push('### Zoom-based Interpolation'); + sections.push('```javascript'); + sections.push('"line-width": ['); + sections.push(' "interpolate",'); + sections.push(' ["exponential", 1.5],'); + sections.push(' ["zoom"],'); + sections.push(' 12, 0.5, // At zoom 12, width is 0.5'); + sections.push(' 18, 20 // At zoom 18, width is 20'); + sections.push(']'); + sections.push('```'); + sections.push(''); + + sections.push('### Feature Property Matching'); + sections.push('```javascript'); + sections.push('filter: ['); + sections.push(' "match",'); + sections.push(' ["get", "class"],'); + sections.push(' ["motorway", "trunk"], true, // Match these values'); + sections.push(' false // Default'); + sections.push(']'); + sections.push('```'); + sections.push(''); + + sections.push('### Conditional Styling'); + sections.push('```javascript'); + sections.push('"fill-color": ['); + sections.push(' "case",'); + sections.push(' ["==", ["get", "type"], "hospital"], "#ff0000",'); + sections.push(' ["==", ["get", "type"], "school"], "#0000ff",'); + sections.push(' "#cccccc" // Default color'); + sections.push(']'); + sections.push('```'); + sections.push(''); + + // Tips + sections.push('## Tips for LLM Usage'); + sections.push(''); + sections.push( + '1. **Layer Order Matters**: Layers are drawn in the order they appear (first = bottom)' + ); + sections.push( + '2. **Use Filters**: Filter by `class`, `type`, or other properties to target specific features' + ); + sections.push( + '3. **Zoom Levels**: Use interpolation for smooth transitions across zoom levels' + ); + sections.push( + '4. **Source Layers**: Most features come from `composite` source with specific `source-layer`' + ); + sections.push( + '5. **Color Formats**: Use hex colors (#rrggbb), rgb(), hsl(), or named colors' + ); + sections.push( + '6. **Opacity**: Use opacity properties for transparency (0 = transparent, 1 = opaque)' + ); + sections.push(''); + + return sections.join('\n'); + } +} + +// Helper function to interpret user requests +export function interpretStyleRequest(userPrompt: string): { + suggestedLayers: string[]; + interpretation: string; +} { + const suggestions = getLayerSuggestions(userPrompt); + + let interpretation = 'Based on your request, you may want to modify: '; + + if (suggestions.length > 0) { + interpretation += suggestions + .map((s) => { + const layer = MAPBOX_STYLE_LAYERS[s]; + return `${s} (${layer?.description || 'unknown'})`; + }) + .join(', '); + } else { + interpretation += + 'No specific layers identified. Please provide more details.'; + } + + return { + suggestedLayers: suggestions, + interpretation + }; +} diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts new file mode 100644 index 0000000..2b4d2e5 --- /dev/null +++ b/src/resources/resourceRegistry.ts @@ -0,0 +1,14 @@ +import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/MapboxStyleLayersResource.js'; + +// Central registry of all resources +export const ALL_RESOURCES = [new MapboxStyleLayersResource()] as const; + +export type ResourceInstance = (typeof ALL_RESOURCES)[number]; + +export function getAllResources(): readonly ResourceInstance[] { + return ALL_RESOURCES; +} + +export function getResourceByUri(uri: string): ResourceInstance | undefined { + return ALL_RESOURCES.find((resource) => resource.uri === uri); +} diff --git a/src/tools/style-builder-tool/StyleBuilderTool.schema.ts b/src/tools/style-builder-tool/StyleBuilderTool.schema.ts new file mode 100644 index 0000000..9d1594d --- /dev/null +++ b/src/tools/style-builder-tool/StyleBuilderTool.schema.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; + +const LayerConfigSchema = z.object({ + layer_type: z + .string() + .describe( + 'Layer type from the resource (e.g., "water", "railways", "parks")' + ), + action: z + .enum(['show', 'hide', 'color', 'highlight']) + .describe('What to do with this layer'), + color: z + .string() + .optional() + .describe('Color value if action is "color" or "highlight"'), + opacity: z.number().min(0).max(1).optional().describe('Opacity value'), + width: z.number().optional().describe('Width for line layers'), + filter: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()) + ]) + .optional() + .describe('Custom filter expression'), + + // Comprehensive property-based filtering + filter_properties: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number(), z.boolean()])) + ]) + ) + .optional() + .describe( + 'Filter by specific properties. Examples: ' + + '{ class: "motorway" } for only motorways, ' + + '{ class: ["motorway", "trunk"] } for multiple road types, ' + + '{ structure: "bridge" } for only bridges, ' + + '{ admin_level: 0, disputed: "false" } for undisputed country boundaries' + ), + + // Expression-based styling + zoom_based: z.boolean().optional().describe('Make styling zoom-dependent'), + min_zoom: z + .number() + .min(0) + .max(24) + .optional() + .describe('Minimum zoom level for zoom-based styling'), + max_zoom: z + .number() + .min(0) + .max(24) + .optional() + .describe('Maximum zoom level for zoom-based styling'), + + // Data-driven styling + property_based: z + .string() + .optional() + .describe('Feature property to base styling on (e.g., "class", "type")'), + property_values: z + .record(z.string(), z.union([z.string(), z.number()])) + .optional() + .describe('Map of property values to styles'), + + // Advanced expressions + expression: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()) + ]) + .optional() + .describe('Custom Mapbox expression for advanced styling') +}); + +export const StyleBuilderToolSchema = z.object({ + style_name: z.string().default('Custom Style').describe('Name for the style'), + + base_style: z + .enum(['streets', 'light', 'dark', 'satellite', 'outdoors', 'blank']) + .default('streets') + .describe('Base style template to start from'), + + layers: z + .array(LayerConfigSchema) + .describe('Layer configurations based on the mapbox-style-layers resource'), + + global_settings: z + .object({ + background_color: z.string().optional().describe('Background/land color'), + label_color: z.string().optional().describe('Default label color'), + mode: z.enum(['light', 'dark']).optional().describe('Light or dark mode') + }) + .optional() + .describe('Global style settings') +}); + +export type StyleBuilderToolInput = z.infer; diff --git a/src/tools/style-builder-tool/StyleBuilderTool.ts b/src/tools/style-builder-tool/StyleBuilderTool.ts new file mode 100644 index 0000000..e6430c5 --- /dev/null +++ b/src/tools/style-builder-tool/StyleBuilderTool.ts @@ -0,0 +1,576 @@ +import { BaseTool } from '../BaseTool.js'; +import { + StyleBuilderToolSchema, + type StyleBuilderToolInput +} from './StyleBuilderTool.schema.js'; +import { MAPBOX_STYLE_LAYERS } from '../../constants/mapboxStyleLayers.js'; +import { STREETS_V8_FIELDS } from '../../constants/mapboxStreetsV8Fields.js'; +import type { MapboxStyle, Layer, Filter } from '../../types/mapbox-style.js'; + +export class StyleBuilderTool extends BaseTool { + name = 'style_builder_tool'; + description = `Build custom Mapbox styles with precise control over layers and visual properties, including zoom-based and data-driven expressions. + +HOW TO CREATE A STYLE: +1. First, consult resource://mapbox-style-layers to see all available layer types +2. Use this tool to generate a style configuration +3. Apply the style using create_style_tool or update_style_tool + +AVAILABLE LAYER TYPES: +• water, waterway - Oceans, lakes, rivers +• landuse, parks - Land areas like parks, hospitals, schools +• buildings, building_3d - Building footprints and 3D extrusions +• roads (motorways, primary_roads, secondary_roads, streets, paths, railways) +• country_boundaries, state_boundaries - Administrative borders +• place_labels, road_labels, poi_labels - Text labels +• landcover - Natural features like forests, grass +• airports - Airport features +• transit - Bus stops, subway entrances, rail stations (filter by maki: bus, entrance, rail-metro) + +ACTIONS YOU CAN APPLY: +• color - Set the layer's color +• highlight - Make layer prominent with color/width +• hide - Remove layer from view +• show - Display layer with default styling + +EXPRESSION FEATURES: +• Zoom-based styling - "Make roads wider at higher zoom levels" +• Data-driven styling - "Color roads based on their class" +• Property-based filters - "Show only international airports" +• Interpolated values - "Fade buildings in between zoom 14 and 16" + +ADVANCED FILTERING: +• "Show only motorways and trunk roads" +• "Display only bridges, not tunnels" +• "Show only paved roads" +• "Display only disputed boundaries" +• "Show only major rail lines, not service rails" +• "Filter POIs by maki icon type (restaurants, hospitals, etc.)" +• "Show only bus stops (transit layer with maki: bus)" +• "Display subway entrances (transit with maki: entrance)" + +COMPREHENSIVE EXAMPLES: +• "Show only motorways that are bridges" +• "Display major rails but exclude tunnels" +• "Color roads: motorways red, primary orange, secondary yellow" +• "Show only toll roads that are paved" +• "Display only civil airports, not military" +• "Show country boundaries excluding maritime ones" +• "Color bus stops red and subway entrances blue (transit with different maki values)" + +For detailed layer properties and filters, check resource://mapbox-style-layers + +TRANSIT FILTERING EXAMPLE: +To show only bus stops: use layer_type: 'transit' with filter_properties: { maki: 'bus' } +To show multiple transit types: filter_properties: { maki: ['bus', 'entrance', 'rail-metro'] }`; + + constructor() { + super({ inputSchema: StyleBuilderToolSchema }); + } + + protected async execute(input: StyleBuilderToolInput) { + try { + const style = this.buildStyle(input); + + return { + content: [ + { + type: 'text' as const, + text: `**Style Built Successfully** + +**Name:** ${input.style_name} +**Base:** ${input.base_style} +**Layers Configured:** ${input.layers.length} + +${this.generateSummary(input)} + +**Generated Style JSON:** +\`\`\`json +${JSON.stringify(style, null, 2)} +\`\`\` + +**Next Steps:** +• Use \`create_style_tool\` with this JSON to create the style in your Mapbox account +• Use \`update_style_tool\` to apply these layers to an existing style +• Use \`preview_style_tool\` to see how this style looks` + } + ], + isError: false + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `**Error building style:** ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + } + + private buildStyle(input: StyleBuilderToolInput): MapboxStyle { + const layers: Layer[] = []; + + // Add background layer + const bgColor = + input.global_settings?.background_color || + (input.global_settings?.mode === 'dark' ? '#1a1a1a' : '#f8f4f0'); + + layers.push({ + id: 'background', + type: 'background', + paint: { + 'background-color': bgColor + } + }); + + // Build each configured layer + for (const config of input.layers) { + if (config.action === 'hide') continue; + + const layerDef = MAPBOX_STYLE_LAYERS[config.layer_type]; + if (!layerDef) { + console.warn(`Unknown layer type: ${config.layer_type}`); + continue; + } + + const layer = this.createLayer(layerDef, config, input.global_settings); + if (layer) { + layers.push(layer); + } + } + + // Add default essential layers if not specified + const configuredTypes = new Set(input.layers.map((l) => l.layer_type)); + const essentialLayers = ['water']; + + for (const layerType of essentialLayers) { + if (!configuredTypes.has(layerType)) { + const layerDef = MAPBOX_STYLE_LAYERS[layerType]; + if (layerDef) { + const layer = this.createLayer( + layerDef, + { + layer_type: layerType, + action: 'show' + }, + input.global_settings + ); + if (layer) { + layers.push(layer); + } + } + } + } + + return { + version: 8, + name: input.style_name, + sources: { + composite: { + type: 'vector', + url: 'mapbox://mapbox.mapbox-streets-v8,mapbox.mapbox-terrain-v2' + } + }, + sprite: 'mapbox://sprites/mapbox/streets-v12', + glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf', + layers + }; + } + + private createLayer( + layerDef: (typeof MAPBOX_STYLE_LAYERS)[keyof typeof MAPBOX_STYLE_LAYERS], + config: StyleBuilderToolInput['layers'][0], + globalSettings?: StyleBuilderToolInput['global_settings'] + ): Layer | null { + const layer: Layer = { + id: `${layerDef.id}-custom`, + type: layerDef.type as Layer['type'] + }; + + // Add source configuration + if (layerDef.sourceLayer) { + layer.source = 'composite'; + layer['source-layer'] = layerDef.sourceLayer; + } + + // Generate comprehensive filter + const filter = this.generateComprehensiveFilter(config, layerDef); + if (filter) { + layer.filter = filter; + } + + // Build paint properties + const paint: Record = {}; + + // Apply color based on action + if ( + (config.action === 'color' || config.action === 'highlight') && + config.color + ) { + const colorProp = this.getColorProperty(layerDef.type); + if (colorProp) { + paint[colorProp] = this.generateExpression( + config.color, + config, + 'color' + ); + } + } + + // Apply opacity if specified + if (config.opacity !== undefined) { + const opacityProp = this.getOpacityProperty(layerDef.type); + if (opacityProp) { + paint[opacityProp] = this.generateExpression( + config.opacity, + config, + 'opacity' + ); + } + } + + // Apply width for line layers + if (config.width !== undefined && layerDef.type === 'line') { + paint['line-width'] = this.generateExpression( + config.width, + config, + 'width' + ); + } + + // For highlight action, make it prominent + if (config.action === 'highlight') { + if (!config.color) { + const colorProp = this.getColorProperty(layerDef.type); + if (colorProp) { + paint[colorProp] = this.generateExpression( + '#ff0000', + config, + 'color' + ); + } + } + if (!config.width && layerDef.type === 'line') { + paint['line-width'] = this.generateExpression(3, config, 'width'); + } + if (config.opacity === undefined) { + const opacityProp = this.getOpacityProperty(layerDef.type); + if (opacityProp) { + paint[opacityProp] = this.generateExpression(1, config, 'opacity'); + } + } + } + + // Apply defaults from layer definition + for (const prop of layerDef.paintProperties) { + if (!(prop.property in paint)) { + // Use a reasonable default + if (prop.property.includes('color') && !prop.example) { + paint[prop.property] = '#808080'; // Default gray + } else if (prop.example !== undefined) { + paint[prop.property] = prop.example; + } + } + } + + // Adjust for dark mode + if (globalSettings?.mode === 'dark') { + if (layer.type === 'symbol') { + paint['text-color'] = paint['text-color'] || '#ffffff'; + paint['text-halo-color'] = '#000000'; + } + } + + if (Object.keys(paint).length > 0) { + layer.paint = paint; + } + + // Add layout properties if needed + if (layerDef.layoutProperties && layerDef.layoutProperties.length > 0) { + const layout: Record = {}; + for (const prop of layerDef.layoutProperties) { + if (prop.example !== undefined) { + layout[prop.property] = prop.example; + } + } + if (Object.keys(layout).length > 0) { + layer.layout = layout; + } + } + + return layer; + } + + private parseFilterString(filterStr: string): unknown | null { + // Parse filter strings like "class: park, cemetery" or "admin_level: 0, maritime: false" + const filters: unknown[] = []; + + // Split by comma if there are multiple conditions + const conditions = filterStr.split(',').map((s) => s.trim()); + + for (const condition of conditions) { + if (condition.includes(':')) { + const [property, values] = condition.split(':').map((s) => s.trim()); + const valueList = values.split('|').map((v) => { + const trimmed = v.trim(); + // Handle boolean strings + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + // Try to parse as number + const num = Number(trimmed); + return isNaN(num) ? trimmed : num; + }); + + if (valueList.length === 1) { + filters.push(['==', ['get', property], valueList[0]]); + } else { + filters.push(['match', ['get', property], valueList, true, false]); + } + } + } + + if (filters.length === 0) return null; + if (filters.length === 1) return filters[0]; + return ['all', ...filters]; + } + + private getColorProperty(layerType: string): string | null { + const colorProps: Record = { + fill: 'fill-color', + line: 'line-color', + symbol: 'text-color', + circle: 'circle-color', + background: 'background-color', + 'fill-extrusion': 'fill-extrusion-color' + }; + + return colorProps[layerType] || null; + } + + private getOpacityProperty(layerType: string): string | null { + const opacityProps: Record = { + fill: 'fill-opacity', + line: 'line-opacity', + symbol: 'text-opacity', + circle: 'circle-opacity', + background: 'background-opacity', + 'fill-extrusion': 'fill-extrusion-opacity' + }; + + return opacityProps[layerType] || null; + } + + private generateSummary(input: StyleBuilderToolInput): string { + const parts: string[] = ['**Layer Configurations:**']; + + for (const config of input.layers) { + const layerDef = MAPBOX_STYLE_LAYERS[config.layer_type]; + const description = layerDef?.description || config.layer_type; + + switch (config.action) { + case 'color': + parts.push(`• ${description}: Set to ${config.color}`); + break; + case 'highlight': + parts.push( + `• ${description}: Highlighted${config.color ? ` in ${config.color}` : ''}` + ); + break; + case 'hide': + parts.push(`• ${description}: Hidden`); + break; + case 'show': + parts.push(`• ${description}: Shown`); + break; + } + } + + if (input.global_settings?.mode) { + parts.push(`\n**Mode:** ${input.global_settings.mode}`); + } + + return parts.join('\n'); + } + + private generateExpression( + value: string | number, + config: StyleBuilderToolInput['layers'][0], + propertyType: 'color' | 'opacity' | 'width' + ): unknown { + // If custom expression is provided, use it + if (config.expression) { + return config.expression; + } + + // Generate property-based styling (data-driven) + if (config.property_based && config.property_values) { + const entries = Object.entries(config.property_values); + const expression: unknown[] = ['match', ['get', config.property_based]]; + + for (const [propValue, styleValue] of entries) { + expression.push(propValue); + expression.push(styleValue); + } + + // Add default value + expression.push(value); + return expression; + } + + // Generate zoom-based interpolation + if (config.zoom_based) { + const minZoom = config.min_zoom ?? 10; + const maxZoom = config.max_zoom ?? 18; + + if (propertyType === 'width') { + // For width, interpolate from smaller to larger + const minWidth = typeof value === 'number' ? value * 0.5 : 1; + const maxWidth = typeof value === 'number' ? value * 2 : 6; + + return [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], + minZoom, + minWidth, + maxZoom, + maxWidth + ]; + } else if (propertyType === 'opacity') { + // For opacity, can fade in/out with zoom + const minOpacity = + typeof value === 'number' ? Math.max(0, value - 0.3) : 0.3; + const maxOpacity = typeof value === 'number' ? value : 1; + + return [ + 'interpolate', + ['linear'], + ['zoom'], + minZoom, + minOpacity, + maxZoom, + maxOpacity + ]; + } else if (propertyType === 'color') { + // For color, use step function for discrete changes + const midZoom = (minZoom + maxZoom) / 2; + return [ + 'step', + ['zoom'], + value, // Default color + midZoom, + value // Could be enhanced to transition between colors + ]; + } + } + + // Return static value if no expression needed + return value; + } + + private generateDataDrivenExpression( + property: string, + valueMap: Record, + defaultValue: unknown + ): unknown { + const expression: unknown[] = ['match', ['get', property]]; + + for (const [key, value] of Object.entries(valueMap)) { + expression.push(key); + expression.push(value); + } + + expression.push(defaultValue); + return expression; + } + + private generateZoomInterpolation( + minZoom: number, + maxZoom: number, + minValue: number, + maxValue: number, + interpolationType: 'linear' | 'exponential' = 'linear' + ): unknown { + const interpolation = + interpolationType === 'exponential' ? ['exponential', 1.5] : ['linear']; + + return [ + 'interpolate', + interpolation, + ['zoom'], + minZoom, + minValue, + maxZoom, + maxValue + ]; + } + + private buildAdvancedFilter( + sourceLayer: string, + filterConfig: Record< + string, + string | number | boolean | (string | number | boolean)[] + > + ): Filter | null { + const filters: unknown[] = []; + + // Get field definitions for this source layer + const layerFields = + STREETS_V8_FIELDS[sourceLayer as keyof typeof STREETS_V8_FIELDS]; + if (!layerFields) return null; + + // Build filter expressions for each property + for (const [property, value] of Object.entries(filterConfig)) { + if (value === undefined || value === null) continue; + + const fieldDef = layerFields[property as keyof typeof layerFields]; + if (!fieldDef) continue; + + // Handle array of values (multiple selections) + if (Array.isArray(value)) { + if (value.length === 1) { + filters.push(['==', ['get', property], value[0]]); + } else if (value.length > 1) { + filters.push(['match', ['get', property], value, true, false]); + } + } + // Handle single value + else { + filters.push(['==', ['get', property], value]); + } + } + + if (filters.length === 0) return null; + if (filters.length === 1) return filters[0] as Filter; + return ['all', ...filters] as Filter; + } + + private generateComprehensiveFilter( + config: StyleBuilderToolInput['layers'][0], + layerDef: (typeof MAPBOX_STYLE_LAYERS)[keyof typeof MAPBOX_STYLE_LAYERS] + ): Filter | null { + // If custom filter is provided, use it + if (config.filter) { + return config.filter as Filter; + } + + // If filter_properties is provided, build from that + if (config.filter_properties && layerDef.sourceLayer) { + return this.buildAdvancedFilter( + layerDef.sourceLayer, + config.filter_properties + ); + } + + // Otherwise, use common filters from layer definition + if (layerDef.commonFilters && layerDef.commonFilters.length > 0) { + const filterStr = layerDef.commonFilters.join(', '); + return this.parseFilterString(filterStr) as Filter; + } + + return null; + } +} diff --git a/src/tools/style-helper-tool/StyleHelperTool.schema.ts b/src/tools/style-helper-tool/StyleHelperTool.schema.ts deleted file mode 100644 index 68e73c7..0000000 --- a/src/tools/style-helper-tool/StyleHelperTool.schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; - -export const StyleHelperToolSchema = z.object({ - step: z - .enum(['start', 'features', 'colors', 'generate']) - .optional() - .describe('Current step in the wizard'), - name: z.string().optional().describe('Name for the style'), - // Feature toggles - show_pois: z.boolean().optional().describe('Show POI labels'), - show_road_labels: z.boolean().optional().describe('Show road labels'), - show_place_labels: z.boolean().optional().describe('Show city/town labels'), - show_transit: z.boolean().optional().describe('Show transit features'), - show_buildings: z.boolean().optional().describe('Show buildings'), - show_parks: z.boolean().optional().describe('Show parks and green spaces'), - // Colors - road_color: z.string().optional().describe('Road color (hex)'), - water_color: z.string().optional().describe('Water color (hex)'), - building_color: z.string().optional().describe('Building color (hex)'), - land_color: z.string().optional().describe('Land/background color (hex)'), - park_color: z.string().optional().describe('Park color (hex)'), - label_color: z.string().optional().describe('Label text color (hex)') -}); - -export type StyleHelperToolInput = z.infer; diff --git a/src/tools/style-helper-tool/StyleHelperTool.ts b/src/tools/style-helper-tool/StyleHelperTool.ts deleted file mode 100644 index 62900d7..0000000 --- a/src/tools/style-helper-tool/StyleHelperTool.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { BaseTool } from '../BaseTool.js'; -import { - StyleHelperToolSchema, - type StyleHelperToolInput -} from './StyleHelperTool.schema.js'; - -export class StyleHelperTool extends BaseTool { - name = 'style_helper_tool'; - description = - 'Interactive helper for creating custom Mapbox styles with specific features and colors'; - - constructor() { - super({ inputSchema: StyleHelperToolSchema }); - } - - protected async execute(input: StyleHelperToolInput) { - const step = input.step || 'start'; - - switch (step) { - case 'start': - return this.handleStart(); - case 'features': - return this.handleFeatures(input); - case 'colors': - return this.handleColors(input); - case 'generate': - return this.handleGenerate(input); - default: - return this.handleStart(); - } - } - - private handleStart() { - return { - content: [ - { - type: 'text' as const, - text: `**Mapbox Style Helper - Initialized** - -**Current step: 1 of 4** - -**Waiting for:** Style name - ---- -**Status: REQUIRES USER INPUT FOR NAME**` - } - ], - isError: false - }; - } - - private handleFeatures(input: StyleHelperToolInput) { - if (!input.name) { - return this.handleStart(); - } - - return { - content: [ - { - type: 'text' as const, - text: `**Style:** ${input.name} - -**Current step: 2 of 4** - -**Waiting for:** Feature toggles - -**Available options:** -• show_place_labels (true/false) -• show_road_labels (true/false) -• show_pois (true/false) -• show_buildings (true/false) -• show_parks (true/false) -• show_transit (true/false) - ---- -**Status: REQUIRES USER FEATURE SELECTION**` - } - ], - isError: false - }; - } - - private handleColors(input: StyleHelperToolInput) { - if (!input.name) { - return this.handleStart(); - } - - const features = this.getFeatureSummary(input); - - return { - content: [ - { - type: 'text' as const, - text: `**Style:** ${input.name} -**Features:** ${features} - -**Current step: 3 of 4** - -**Waiting for:** Color values - -**Required:** -• road_color (hex) -• water_color (hex) -• land_color (hex) -• label_color (hex) - -**Optional:** -• building_color (hex) -• park_color (hex) - ---- -**Status: REQUIRES USER COLOR SELECTION**` - } - ], - isError: false - }; - } - - private handleGenerate(input: StyleHelperToolInput) { - if ( - !input.name || - !input.road_color || - !input.water_color || - !input.land_color || - !input.label_color - ) { - return { - content: [ - { - type: 'text' as const, - text: 'Missing required colors. Please complete all color steps.' - } - ], - isError: true - }; - } - - const style = this.generateStyle(input); - - return { - content: [ - { - type: 'text' as const, - text: `**COMPLETED: Style Generated** - -**Name:** ${input.name} - -**Final Configuration:** -• POIs: ${input.show_pois ? 'shown' : 'hidden'} -• Road Labels: ${input.show_road_labels ? 'shown' : 'hidden'} -• Place Labels: ${input.show_place_labels ? 'shown' : 'hidden'} -• Transit: ${input.show_transit ? 'shown' : 'hidden'} -• Buildings: ${input.show_buildings ? 'shown' : 'hidden'} -• Parks: ${input.show_parks ? 'shown' : 'hidden'} - -**Colors:** -• Roads: ${input.road_color} -• Water: ${input.water_color} -• Buildings: ${input.building_color || '#e0e0e0'} -• Land: ${input.land_color} -• Parks: ${input.park_color || '#d0e5d0'} -• Labels: ${input.label_color} - -**Generated Style JSON:** -\`\`\`json -${JSON.stringify(style, null, 2)} -\`\`\` - ---- -**Status: STYLE GENERATION COMPLETE**` - } - ], - isError: false - }; - } - - private generateStyle(input: StyleHelperToolInput) { - const layers: Record[] = [ - // Background - { - id: 'land', - type: 'background', - paint: { - 'background-color': input.land_color - } - }, - // Water - { - id: 'water', - type: 'fill', - source: 'composite', - 'source-layer': 'water', - paint: { - 'fill-color': input.water_color - } - } - ]; - - // Parks (if enabled) - if (input.show_parks) { - layers.push({ - id: 'landuse_park', - type: 'fill', - source: 'composite', - 'source-layer': 'landuse', - filter: ['==', ['get', 'class'], 'park'], - paint: { - 'fill-color': input.park_color || '#d0e5d0', - 'fill-opacity': 0.8 - } - }); - } - - // Roads - simplified with just two layers - layers.push({ - id: 'road', - type: 'line', - source: 'composite', - 'source-layer': 'road', - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, - paint: { - 'line-color': input.road_color, - 'line-width': [ - 'interpolate', - ['exponential', 1.5], - ['zoom'], - 5, - 0.5, - 18, - 20 - ] - } - }); - - // Buildings (if enabled) - if (input.show_buildings) { - layers.push({ - id: 'building', - type: 'fill', - source: 'composite', - 'source-layer': 'building', - minzoom: 14, - paint: { - 'fill-color': input.building_color || '#e0e0e0', - 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1] - } - }); - } - - // Place labels (if enabled) - if (input.show_place_labels !== false) { - // Default to true - layers.push({ - id: 'place_label', - type: 'symbol', - source: 'composite', - 'source-layer': 'place_label', - layout: { - 'text-field': ['get', 'name'], - 'text-font': ['DIN Pro Medium', 'Arial Unicode MS Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 12, 16, 20] - }, - paint: { - 'text-color': input.label_color, - 'text-halo-color': input.land_color, - 'text-halo-width': 1.5 - } - }); - } - - // Road labels (if enabled) - if (input.show_road_labels) { - const roadLabel: Record = { - id: 'road_label', - type: 'symbol', - source: 'composite', - 'source-layer': 'road', - minzoom: 13, - layout: { - 'symbol-placement': 'line', - 'text-field': ['get', 'name'], - 'text-font': ['DIN Pro Regular', 'Arial Unicode MS Regular'], - 'text-size': 12 - }, - paint: { - 'text-color': input.label_color, - 'text-halo-color': input.land_color, - 'text-halo-width': 1 - } - }; - layers.push(roadLabel); - } - - // POI labels (if enabled) - if (input.show_pois) { - const poiLabel: Record = { - id: 'poi_label', - type: 'symbol', - source: 'composite', - 'source-layer': 'poi_label', - minzoom: 13, - layout: { - 'text-field': ['get', 'name'], - 'text-font': ['DIN Pro Regular', 'Arial Unicode MS Regular'], - 'text-size': 11 - }, - paint: { - 'text-color': input.label_color, - 'text-halo-color': input.land_color, - 'text-halo-width': 1 - } - }; - layers.push(poiLabel); - } - - return { - version: 8, - name: input.name, - metadata: { - 'mapbox:autocomposite': true - }, - sources: { - composite: { - type: 'vector', - url: 'mapbox://mapbox.mapbox-terrain-v2,mapbox.mapbox-streets-v8' - } - }, - sprite: 'mapbox://sprites/mapbox/streets-v12', - glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf', - layers: layers - }; - } - - private getFeatureSummary(input: StyleHelperToolInput): string { - const features = []; - if (input.show_pois) features.push('POIs'); - if (input.show_road_labels) features.push('road labels'); - if (input.show_place_labels) features.push('place labels'); - if (input.show_transit) features.push('transit'); - if (input.show_buildings) features.push('buildings'); - if (input.show_parks) features.push('parks'); - - return features.length > 0 ? features.join(', ') : 'none selected yet'; - } -} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 8c8ee2d..68666ea 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -10,8 +10,8 @@ import { ListStylesTool } from './list-styles-tool/ListStylesTool.js'; import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js'; import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js'; import { RetrieveStyleTool } from './retrieve-style-tool/RetrieveStyleTool.js'; +import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; -import { StyleHelperTool } from './style-helper-tool/StyleHelperTool.js'; import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; @@ -23,7 +23,7 @@ export const ALL_TOOLS = [ new UpdateStyleTool(), new DeleteStyleTool(), new PreviewStyleTool(), - new StyleHelperTool(), + new StyleBuilderTool(), new GeojsonPreviewTool(), new CreateTokenTool(), new ListTokensTool(), diff --git a/src/types/mapbox-style.ts b/src/types/mapbox-style.ts new file mode 100644 index 0000000..55bfead --- /dev/null +++ b/src/types/mapbox-style.ts @@ -0,0 +1,498 @@ +/** + * Comprehensive Mapbox Style Specification types with full expression support + */ + +// Expression Types +export type Expression = + // Literals + | string + | number + | boolean + | null + | Expression[] + // Property accessors + | ['get', string] + | ['get', string, unknown] + | ['has', string] + | ['has', string, unknown] + | ['at', number, Expression] + | ['in', Expression, Expression] + | ['index-of', Expression, Expression] + | ['length', Expression] + | ['geometry-type'] + | ['id'] + | ['properties'] + | ['feature-state', string] + // Type functions + | ['typeof', Expression] + | ['to-string', Expression] + | ['to-number', Expression] + | ['to-boolean', Expression] + | ['to-color', Expression] + | ['to-rgba', Expression] + // Logical + | ['!', Expression] + | ['!=', Expression, Expression] + | ['<', Expression, Expression] + | ['<=', Expression, Expression] + | ['==', Expression, Expression] + | ['>', Expression, Expression] + | ['>=', Expression, Expression] + | ['all', ...Expression[]] + | ['any', ...Expression[]] + | ['case', ...Expression[]] + | ['coalesce', ...Expression[]] + | ['match', Expression, ...any[]] + | ['within', unknown] + // Math + | ['+', ...Expression[]] + | ['-', Expression, Expression?] + | ['*', ...Expression[]] + | ['/', Expression, Expression] + | ['%', Expression, Expression] + | ['^', Expression, Expression] + | ['abs', Expression] + | ['acos', Expression] + | ['asin', Expression] + | ['atan', Expression] + | ['ceil', Expression] + | ['cos', Expression] + | ['distance', unknown] + | ['e'] + | ['floor', Expression] + | ['ln', Expression] + | ['ln2'] + | ['log10', Expression] + | ['log2', Expression] + | ['max', ...Expression[]] + | ['min', ...Expression[]] + | ['pi'] + | ['round', Expression] + | ['sin', Expression] + | ['sqrt', Expression] + | ['tan', Expression] + // String + | ['concat', ...Expression[]] + | ['downcase', Expression] + | ['upcase', Expression] + | ['slice', Expression, Expression, Expression?] + // Color + | ['rgb', Expression, Expression, Expression] + | ['rgba', Expression, Expression, Expression, Expression] + | ['hsl', Expression, Expression, Expression] + | ['hsla', Expression, Expression, Expression, Expression] + // Interpolation + | ['interpolate', Interpolation, Expression, ...any[]] + | ['interpolate-hcl', Interpolation, Expression, ...any[]] + | ['interpolate-lab', Interpolation, Expression, ...any[]] + | ['step', Expression, Expression, ...any[]] + // Variable binding + | ['let', ...any[]] + | ['var', string] + // Zoom + | ['zoom'] + | ['heatmap-density'] + | ['line-progress'] + | ['sky-radial-progress'] + | ['accumulated'] + // Special + | ['literal', any] + | ['image', Expression] + | ['format', ...any[]] + | ['number-format', Expression, unknown] + | ['collator', unknown] + | ['resolved-locale', unknown] + | ['is-supported-script', Expression] + | ['pitch'] + | ['distance-from-center'] + | ['raster-value'] + | ['raster-particle-speed']; + +export type Interpolation = + | ['linear'] + | ['exponential', number] + | ['cubic-bezier', number, number, number, number]; + +// Filter Types - Legacy and Expression-based +export type Filter = + | ['has', string] + | ['!has', string] + | ['==', string | ['get', string] | ['geometry-type'], any] + | ['!=', string | ['get', string] | ['geometry-type'], any] + | ['>', string | ['get', string], number] + | ['>=', string | ['get', string], number] + | ['<', string | ['get', string], number] + | ['<=', string | ['get', string], number] + | ['in', string | ['get', string], ...any[]] + | ['!in', string | ['get', string], ...any[]] + | ['all', ...Filter[]] + | ['any', ...Filter[]] + | ['none', ...Filter[]] + | Expression; // Modern expression-based filters + +// Layer Types +export interface BaseLayer { + id: string; + type: string; + metadata?: Record; + source?: string; + 'source-layer'?: string; + minzoom?: number; + maxzoom?: number; + filter?: Filter; + layout?: Record; + paint?: Record; +} + +export interface BackgroundLayer extends BaseLayer { + type: 'background'; + paint?: { + 'background-color'?: Expression | string; + 'background-opacity'?: Expression | number; + 'background-pattern'?: Expression | string; + }; +} + +export interface FillLayer extends BaseLayer { + type: 'fill'; + paint?: { + 'fill-antialias'?: Expression | boolean; + 'fill-color'?: Expression | string; + 'fill-opacity'?: Expression | number; + 'fill-outline-color'?: Expression | string; + 'fill-pattern'?: Expression | string; + 'fill-sort-key'?: Expression | number; + 'fill-translate'?: Expression | [number, number]; + 'fill-translate-anchor'?: 'map' | 'viewport'; + }; +} + +export interface LineLayer extends BaseLayer { + type: 'line'; + layout?: { + 'line-cap'?: Expression | 'butt' | 'round' | 'square'; + 'line-join'?: Expression | 'bevel' | 'round' | 'miter'; + 'line-miter-limit'?: Expression | number; + 'line-round-limit'?: Expression | number; + 'line-sort-key'?: Expression | number; + }; + paint?: { + 'line-blur'?: Expression | number; + 'line-color'?: Expression | string; + 'line-dasharray'?: Expression | number[]; + 'line-gap-width'?: Expression | number; + 'line-gradient'?: Expression | string; + 'line-offset'?: Expression | number; + 'line-opacity'?: Expression | number; + 'line-pattern'?: Expression | string; + 'line-translate'?: Expression | [number, number]; + 'line-translate-anchor'?: 'map' | 'viewport'; + 'line-width'?: Expression | number; + }; +} + +export interface SymbolLayer extends BaseLayer { + type: 'symbol'; + layout?: { + 'symbol-placement'?: 'point' | 'line' | 'line-center'; + 'symbol-spacing'?: Expression | number; + 'symbol-avoid-edges'?: boolean; + 'symbol-sort-key'?: Expression | number; + 'symbol-z-order'?: 'auto' | 'viewport-y' | 'source'; + 'icon-allow-overlap'?: Expression | boolean; + 'icon-anchor'?: Expression | string; + 'icon-ignore-placement'?: Expression | boolean; + 'icon-image'?: Expression | string; + 'icon-keep-upright'?: boolean; + 'icon-offset'?: Expression | [number, number]; + 'icon-optional'?: boolean; + 'icon-padding'?: Expression | number; + 'icon-pitch-alignment'?: 'map' | 'viewport' | 'auto'; + 'icon-rotate'?: Expression | number; + 'icon-rotation-alignment'?: 'map' | 'viewport' | 'auto'; + 'icon-size'?: Expression | number; + 'icon-text-fit'?: 'none' | 'width' | 'height' | 'both'; + 'icon-text-fit-padding'?: Expression | [number, number, number, number]; + 'text-allow-overlap'?: Expression | boolean; + 'text-anchor'?: Expression | string; + 'text-field'?: Expression | string; + 'text-font'?: Expression | string[]; + 'text-ignore-placement'?: Expression | boolean; + 'text-justify'?: Expression | 'auto' | 'left' | 'center' | 'right'; + 'text-keep-upright'?: boolean; + 'text-letter-spacing'?: Expression | number; + 'text-line-height'?: Expression | number; + 'text-max-angle'?: Expression | number; + 'text-max-width'?: Expression | number; + 'text-offset'?: Expression | [number, number]; + 'text-optional'?: boolean; + 'text-padding'?: Expression | number; + 'text-pitch-alignment'?: 'map' | 'viewport' | 'auto'; + 'text-radial-offset'?: Expression | number; + 'text-rotate'?: Expression | number; + 'text-rotation-alignment'?: 'map' | 'viewport' | 'auto'; + 'text-size'?: Expression | number; + 'text-transform'?: Expression | 'none' | 'uppercase' | 'lowercase'; + 'text-variable-anchor'?: string[]; + 'text-writing-mode'?: string[]; + }; + paint?: { + 'icon-color'?: Expression | string; + 'icon-halo-blur'?: Expression | number; + 'icon-halo-color'?: Expression | string; + 'icon-halo-width'?: Expression | number; + 'icon-opacity'?: Expression | number; + 'icon-translate'?: Expression | [number, number]; + 'icon-translate-anchor'?: 'map' | 'viewport'; + 'text-color'?: Expression | string; + 'text-halo-blur'?: Expression | number; + 'text-halo-color'?: Expression | string; + 'text-halo-width'?: Expression | number; + 'text-opacity'?: Expression | number; + 'text-translate'?: Expression | [number, number]; + 'text-translate-anchor'?: 'map' | 'viewport'; + }; +} + +export interface CircleLayer extends BaseLayer { + type: 'circle'; + paint?: { + 'circle-blur'?: Expression | number; + 'circle-color'?: Expression | string; + 'circle-opacity'?: Expression | number; + 'circle-pitch-alignment'?: 'map' | 'viewport'; + 'circle-pitch-scale'?: 'map' | 'viewport'; + 'circle-radius'?: Expression | number; + 'circle-stroke-color'?: Expression | string; + 'circle-stroke-opacity'?: Expression | number; + 'circle-stroke-width'?: Expression | number; + 'circle-translate'?: Expression | [number, number]; + 'circle-translate-anchor'?: 'map' | 'viewport'; + }; +} + +export interface RasterLayer extends BaseLayer { + type: 'raster'; + paint?: { + 'raster-brightness-max'?: Expression | number; + 'raster-brightness-min'?: Expression | number; + 'raster-contrast'?: Expression | number; + 'raster-fade-duration'?: number; + 'raster-hue-rotate'?: Expression | number; + 'raster-opacity'?: Expression | number; + 'raster-resampling'?: 'linear' | 'nearest'; + 'raster-saturation'?: Expression | number; + }; +} + +export interface HillshadeLayer extends BaseLayer { + type: 'hillshade'; + paint?: { + 'hillshade-accent-color'?: Expression | string; + 'hillshade-exaggeration'?: Expression | number; + 'hillshade-highlight-color'?: Expression | string; + 'hillshade-illumination-anchor'?: 'map' | 'viewport'; + 'hillshade-illumination-direction'?: Expression | number; + 'hillshade-shadow-color'?: Expression | string; + }; +} + +export interface HeatmapLayer extends BaseLayer { + type: 'heatmap'; + paint?: { + 'heatmap-color'?: Expression; + 'heatmap-intensity'?: Expression | number; + 'heatmap-opacity'?: Expression | number; + 'heatmap-radius'?: Expression | number; + 'heatmap-weight'?: Expression | number; + }; +} + +export interface FillExtrusionLayer extends BaseLayer { + type: 'fill-extrusion'; + paint?: { + 'fill-extrusion-base'?: Expression | number; + 'fill-extrusion-color'?: Expression | string; + 'fill-extrusion-height'?: Expression | number; + 'fill-extrusion-opacity'?: Expression | number; + 'fill-extrusion-pattern'?: Expression | string; + 'fill-extrusion-translate'?: Expression | [number, number]; + 'fill-extrusion-translate-anchor'?: 'map' | 'viewport'; + 'fill-extrusion-vertical-gradient'?: boolean; + }; +} + +export interface SkyLayer extends BaseLayer { + type: 'sky'; + paint?: { + 'sky-atmosphere-color'?: Expression | string; + 'sky-atmosphere-halo-color'?: Expression | string; + 'sky-atmosphere-sun'?: Expression | [number, number]; + 'sky-atmosphere-sun-intensity'?: Expression | number; + 'sky-gradient'?: Expression | string; + 'sky-gradient-center'?: Expression | [number, number]; + 'sky-gradient-radius'?: Expression | number; + 'sky-opacity'?: Expression | number; + 'sky-type'?: 'gradient' | 'atmosphere'; + }; +} + +export type Layer = + | BackgroundLayer + | FillLayer + | LineLayer + | SymbolLayer + | CircleLayer + | RasterLayer + | HillshadeLayer + | HeatmapLayer + | FillExtrusionLayer + | SkyLayer; + +// Source Types +export interface VectorSource { + type: 'vector'; + url?: string; + tiles?: string[]; + bounds?: [number, number, number, number]; + scheme?: 'xyz' | 'tms'; + minzoom?: number; + maxzoom?: number; + attribution?: string; + promoteId?: string | Record; + volatile?: boolean; +} + +export interface RasterSource { + type: 'raster'; + url?: string; + tiles?: string[]; + bounds?: [number, number, number, number]; + minzoom?: number; + maxzoom?: number; + tileSize?: number; + scheme?: 'xyz' | 'tms'; + attribution?: string; + volatile?: boolean; +} + +export interface RasterDemSource { + type: 'raster-dem'; + url?: string; + tiles?: string[]; + bounds?: [number, number, number, number]; + minzoom?: number; + maxzoom?: number; + tileSize?: number; + attribution?: string; + encoding?: 'terrarium' | 'mapbox'; + volatile?: boolean; +} + +export interface GeoJSONSource { + type: 'geojson'; + data?: unknown; + maxzoom?: number; + attribution?: string; + buffer?: number; + filter?: unknown; + tolerance?: number; + cluster?: boolean; + clusterRadius?: number; + clusterMaxZoom?: number; + clusterMinPoints?: number; + clusterProperties?: Record; + lineMetrics?: boolean; + generateId?: boolean; + promoteId?: string | Record; +} + +export interface ImageSource { + type: 'image'; + url?: string; + coordinates?: [ + [number, number], + [number, number], + [number, number], + [number, number] + ]; +} + +export interface VideoSource { + type: 'video'; + urls?: string[]; + coordinates?: [ + [number, number], + [number, number], + [number, number], + [number, number] + ]; +} + +export type Source = + | VectorSource + | RasterSource + | RasterDemSource + | GeoJSONSource + | ImageSource + | VideoSource; + +// Main Style Type +export interface MapboxStyle { + version: 8; + name?: string; + metadata?: Record; + center?: [number, number]; + zoom?: number; + bearing?: number; + pitch?: number; + light?: { + anchor?: 'map' | 'viewport'; + position?: [number, number, number]; + color?: string; + intensity?: number; + }; + terrain?: { + source: string; + exaggeration?: Expression | number; + }; + fog?: { + range?: Expression | [number, number]; + color?: Expression | string; + 'horizon-blend'?: Expression | number; + 'high-color'?: Expression | string; + 'space-color'?: Expression | string; + 'star-intensity'?: Expression | number; + }; + sources: Record; + sprite?: string; + glyphs?: string; + transition?: { + duration?: number; + delay?: number; + }; + projection?: { + name: + | 'albers' + | 'equalEarth' + | 'equirectangular' + | 'lambertConformalConic' + | 'mercator' + | 'naturalEarth' + | 'winkelTripel' + | 'globe'; + center?: [number, number]; + parallels?: [number, number]; + }; + layers: Layer[]; +} + +// Style Diff Type for comparison +export interface StyleDiff { + added: Layer[]; + removed: Layer[]; + modified: Array<{ + id: string; + changes: Record; + }>; +} diff --git a/src/utils/versionUtils.ts b/src/utils/versionUtils.ts index d4f06f8..44779ea 100644 --- a/src/utils/versionUtils.ts +++ b/src/utils/versionUtils.ts @@ -1,6 +1,6 @@ -import { readFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; export interface VersionInfo { name: string; diff --git a/test/resources/MapboxStyleLayersResource.test.ts b/test/resources/MapboxStyleLayersResource.test.ts new file mode 100644 index 0000000..6456976 --- /dev/null +++ b/test/resources/MapboxStyleLayersResource.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { MapboxStyleLayersResource } from '../../src/resources/mapbox-style-layers-resource/MapboxStyleLayersResource.js'; + +describe('MapboxStyleLayersResource', () => { + let resource: MapboxStyleLayersResource; + + beforeEach(() => { + resource = new MapboxStyleLayersResource(); + }); + + describe('basic properties', () => { + it('should have correct name and URI', () => { + expect(resource.name).toBe('Mapbox Style Layers Guide'); + expect(resource.uri).toBe('resource://mapbox-style-layers'); + expect(resource.mimeType).toBe('text/markdown'); + }); + + it('should have a description', () => { + expect(resource.description).toContain( + 'Comprehensive guide for Mapbox style layers' + ); + }); + }); +}); diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index 1de3a70..ac7e99f 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -62,16 +62,69 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Retrieve a specific Mapbox style by ID", "toolName": "retrieve_style_tool", }, + { + "className": "StyleBuilderTool", + "description": "Build custom Mapbox styles with precise control over layers and visual properties, including zoom-based and data-driven expressions. + +HOW TO CREATE A STYLE: +1. First, consult resource://mapbox-style-layers to see all available layer types +2. Use this tool to generate a style configuration +3. Apply the style using create_style_tool or update_style_tool + +AVAILABLE LAYER TYPES: +• water, waterway - Oceans, lakes, rivers +• landuse, parks - Land areas like parks, hospitals, schools +• buildings, building_3d - Building footprints and 3D extrusions +• roads (motorways, primary_roads, secondary_roads, streets, paths, railways) +• country_boundaries, state_boundaries - Administrative borders +• place_labels, road_labels, poi_labels - Text labels +• landcover - Natural features like forests, grass +• airports - Airport features +• transit - Bus stops, subway entrances, rail stations (filter by maki: bus, entrance, rail-metro) + +ACTIONS YOU CAN APPLY: +• color - Set the layer's color +• highlight - Make layer prominent with color/width +• hide - Remove layer from view +• show - Display layer with default styling + +EXPRESSION FEATURES: +• Zoom-based styling - "Make roads wider at higher zoom levels" +• Data-driven styling - "Color roads based on their class" +• Property-based filters - "Show only international airports" +• Interpolated values - "Fade buildings in between zoom 14 and 16" + +ADVANCED FILTERING: +• "Show only motorways and trunk roads" +• "Display only bridges, not tunnels" +• "Show only paved roads" +• "Display only disputed boundaries" +• "Show only major rail lines, not service rails" +• "Filter POIs by maki icon type (restaurants, hospitals, etc.)" +• "Show only bus stops (transit layer with maki: bus)" +• "Display subway entrances (transit with maki: entrance)" + +COMPREHENSIVE EXAMPLES: +• "Show only motorways that are bridges" +• "Display major rails but exclude tunnels" +• "Color roads: motorways red, primary orange, secondary yellow" +• "Show only toll roads that are paved" +• "Display only civil airports, not military" +• "Show country boundaries excluding maritime ones" +• "Color bus stops red and subway entrances blue (transit with different maki values)" + +For detailed layer properties and filters, check resource://mapbox-style-layers + +TRANSIT FILTERING EXAMPLE: +To show only bus stops: use layer_type: 'transit' with filter_properties: { maki: 'bus' } +To show multiple transit types: filter_properties: { maki: ['bus', 'entrance', 'rail-metro'] }", + "toolName": "style_builder_tool", + }, { "className": "StyleComparisonTool", "description": "Generate a comparison URL for comparing two Mapbox styles side-by-side", "toolName": "style_comparison_tool", }, - { - "className": "StyleHelperTool", - "description": "Interactive helper for creating custom Mapbox styles with specific features and colors", - "toolName": "style_helper_tool", - }, { "className": "TilequeryTool", "description": "Query vector and raster data from Mapbox tilesets at geographic coordinates", diff --git a/test/tools/style-builder-tool/StyleBuilderTool.test.ts b/test/tools/style-builder-tool/StyleBuilderTool.test.ts new file mode 100644 index 0000000..c838360 --- /dev/null +++ b/test/tools/style-builder-tool/StyleBuilderTool.test.ts @@ -0,0 +1,701 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StyleBuilderTool } from '../../../src/tools/style-builder-tool/StyleBuilderTool.js'; +import type { StyleBuilderToolInput } from '../../../src/tools/style-builder-tool/StyleBuilderTool.schema.js'; + +describe('StyleBuilderTool', () => { + let tool: StyleBuilderTool; + + beforeEach(() => { + tool = new StyleBuilderTool(); + }); + + describe('basic functionality', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('style_builder_tool'); + expect(tool.description).toContain('Build custom Mapbox styles'); + }); + + it('should build a basic style with water layer', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Test Style', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'water', + action: 'color', + color: '#0066ff' + } + ] + }; + + const result = await tool.execute(input); + + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('text'); + + const text = result.content[0].text; + expect(text).toContain('Style Built Successfully'); + expect(text).toContain('Test Style'); + expect(text).toContain('"#0066ff"'); + }); + + it('should handle dark mode', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Dark Mode Style', + base_style: 'streets-v12', + layers: [], + global_settings: { + mode: 'dark', + background_color: '#000000' + } + }; + + const result = await tool.execute(input); + + expect(result.isError).toBe(false); + const text = result.content[0].text; + expect(text).toContain('Mode:** dark'); + expect(text).toContain('#000000'); + }); + }); + + describe('layer actions', () => { + it('should handle color action', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Color Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'primary_roads', + action: 'color', + color: '#ff0000' + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + expect(text).toContain('#ff0000'); + }); + + it('should handle highlight action', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Highlight Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'railways', + action: 'highlight', + color: '#ffff00', + width: 5 + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + expect(text).toContain('Highlighted'); + expect(text).toContain('#ffff00'); + }); + + it('should handle hide action', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Hide Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'place_labels', + action: 'hide' + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + expect(text).toContain('Hidden'); + }); + + it('should handle show action', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Show Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'buildings', + action: 'show' + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + expect(text).toContain('Shown'); + }); + }); + + describe('administrative boundaries', () => { + it('should handle country boundaries with correct filters', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Country Boundaries Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'country_boundaries', + action: 'color', + color: '#ff0000', + width: 3 + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + + // Extract JSON from result + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + expect(jsonMatch).toBeTruthy(); + + const style = JSON.parse(jsonMatch![1]); + + // Find the country boundaries layer + const countryLayer = style.layers.find( + (l: any) => l.id === 'admin-0-boundary-custom' + ); + expect(countryLayer).toBeTruthy(); + expect(countryLayer['source-layer']).toBe('admin'); + + // Check filter includes admin_level + const filterStr = JSON.stringify(countryLayer.filter); + expect(filterStr).toContain('admin_level'); + expect(filterStr).toContain('0'); + expect(filterStr).toContain('maritime'); + expect(filterStr).toContain('false'); + }); + + it('should handle state boundaries', async () => { + const input: StyleBuilderToolInput = { + style_name: 'State Boundaries Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'state_boundaries', + action: 'color', + color: '#0000ff', + opacity: 0.5 + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + expect(result.isError).toBe(false); + + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const stateLayer = style.layers.find( + (l: any) => l.id === 'admin-1-boundary-custom' + ); + expect(stateLayer).toBeTruthy(); + expect(stateLayer['source-layer']).toBe('admin'); + + const filterStr = JSON.stringify(stateLayer.filter); + expect(filterStr).toContain('admin_level'); + expect(filterStr).toContain('1'); + }); + }); + + describe('style generation', () => { + it('should generate valid Mapbox style JSON', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Valid Style Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'water', + action: 'color', + color: '#0099ff' + }, + { + layer_type: 'parks', + action: 'color', + color: '#00ff00' + } + ] + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + expect(jsonMatch).toBeTruthy(); + + const style = JSON.parse(jsonMatch![1]); + + // Check basic style structure + expect(style.version).toBe(8); + expect(style.name).toBe('Valid Style Test'); + expect(style.sources).toBeTruthy(); + expect(style.sources.composite).toBeTruthy(); + expect(style.sources.composite.url).toBe( + 'mapbox://mapbox.mapbox-streets-v8,mapbox.mapbox-terrain-v2' + ); + expect(style.sprite).toContain('streets-v12'); + expect(style.glyphs).toContain('mapbox://fonts'); + expect(Array.isArray(style.layers)).toBe(true); + + // Check background layer is always added + const bgLayer = style.layers.find((l: any) => l.id === 'background'); + expect(bgLayer).toBeTruthy(); + }); + + it('should include essential layers by default', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Essential Layers Test', + base_style: 'streets-v12', + layers: [] // No layers specified + }; + + const result = await tool.execute(input); + const text = result.content[0].text; + + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + // Should have at least background and water + expect(style.layers.length).toBeGreaterThanOrEqual(2); + + const bgLayer = style.layers.find((l: any) => l.id === 'background'); + const waterLayer = style.layers.find((l: any) => l.id === 'water-custom'); + + expect(bgLayer).toBeTruthy(); + expect(waterLayer).toBeTruthy(); + }); + }); + + describe('error handling', () => { + it('should handle unknown layer types gracefully', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Unknown Layer Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'unknown_layer' as any, + action: 'color', + color: '#ff0000' + } + ] + }; + + const result = await tool.execute(input); + + // Should not error, just skip unknown layer + expect(result.isError).toBe(false); + const text = result.content[0].text; + expect(text).toContain('Style Built Successfully'); + }); + + it('should handle custom filters', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Custom Filter Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'motorways', + action: 'color', + color: '#ff0000', + filter: ['==', ['get', 'class'], 'motorway'] + } + ] + }; + + const result = await tool.execute(input); + + expect(result.isError).toBe(false); + const text = result.content[0].text; + + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const motorwayLayer = style.layers.find( + (l: any) => l.id && l.id.includes('motorway') + ); + expect(motorwayLayer).toBeTruthy(); + expect(JSON.stringify(motorwayLayer.filter)).toContain('motorway'); + }); + }); + + describe('expression generation', () => { + it('should generate zoom-based expressions', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Zoom Expression Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'motorways', + action: 'color', + color: '#ff0000', + width: 3, + zoom_based: true, + min_zoom: 10, + max_zoom: 18 + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const motorwayLayer = style.layers.find((l: any) => + l.id.includes('motorway') + ); + expect(motorwayLayer).toBeTruthy(); + + // Check for zoom interpolation in line-width + const lineWidth = motorwayLayer.paint['line-width']; + expect(Array.isArray(lineWidth)).toBe(true); + expect(lineWidth[0]).toBe('interpolate'); + expect(lineWidth[2]).toEqual(['zoom']); + }); + + it('should generate data-driven expressions', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Data Driven Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'primary_roads', + action: 'color', + color: '#000000', + property_based: 'class', + property_values: { + motorway: '#ff0000', + primary: '#ff8800', + secondary: '#ffff00' + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const roadLayer = style.layers.find((l: any) => l.id.includes('primary')); + expect(roadLayer).toBeTruthy(); + + // Check for match expression in line-color + const lineColor = roadLayer.paint['line-color']; + expect(Array.isArray(lineColor)).toBe(true); + expect(lineColor[0]).toBe('match'); + expect(lineColor[1]).toEqual(['get', 'class']); + expect(lineColor).toContain('motorway'); + expect(lineColor).toContain('#ff0000'); + }); + + it('should handle custom expressions', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Custom Expression Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'buildings', + action: 'color', + color: '#808080', + expression: [ + 'case', + ['>', ['get', 'height'], 100], + '#ff0000', + ['>', ['get', 'height'], 50], + '#ff8800', + '#808080' + ] + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const buildingLayer = style.layers.find((l: any) => + l.id.includes('building') + ); + expect(buildingLayer).toBeTruthy(); + + // Check for case expression + const fillColor = buildingLayer.paint['fill-color']; + expect(Array.isArray(fillColor)).toBe(true); + expect(fillColor[0]).toBe('case'); + }); + + it('should generate opacity interpolation with zoom', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Opacity Zoom Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'buildings', + action: 'show', + opacity: 0.8, + zoom_based: true, + min_zoom: 14, + max_zoom: 16 + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const buildingLayer = style.layers.find((l: any) => + l.id.includes('building') + ); + expect(buildingLayer).toBeTruthy(); + + // Check for opacity interpolation + const fillOpacity = buildingLayer.paint['fill-opacity']; + expect(Array.isArray(fillOpacity)).toBe(true); + expect(fillOpacity[0]).toBe('interpolate'); + expect(fillOpacity[2]).toEqual(['zoom']); + expect(fillOpacity).toContain(14); + expect(fillOpacity).toContain(16); + }); + }); + + describe('transit filtering', () => { + it('should filter transit stops by maki type', async () => { + const tool = new StyleBuilderTool(); + const input: StyleBuilderToolInput = { + style_name: 'Transit Test', + base_style: 'streets', + layers: [ + { + layer_type: 'transit', + action: 'color', + color: '#ff0000', + filter_properties: { + maki: 'bus' + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const styleJson = JSON.parse( + result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] + ); + + const transitLayer = styleJson.layers.find((l: any) => + l.id.includes('transit') + ); + expect(transitLayer).toBeDefined(); + expect(transitLayer.filter).toEqual(['==', ['get', 'maki'], 'bus']); + }); + + it('should filter multiple transit types', async () => { + const tool = new StyleBuilderTool(); + const input: StyleBuilderToolInput = { + style_name: 'Multi Transit Test', + base_style: 'streets', + layers: [ + { + layer_type: 'transit', + action: 'highlight', + filter_properties: { + maki: ['bus', 'entrance', 'rail-metro'] + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const styleJson = JSON.parse( + result.content[0].text.match(/```json\n([\s\S]*?)\n```/)![1] + ); + + const transitLayer = styleJson.layers.find((l: any) => + l.id.includes('transit') + ); + expect(transitLayer).toBeDefined(); + expect(transitLayer.filter).toEqual([ + 'match', + ['get', 'maki'], + ['bus', 'entrance', 'rail-metro'], + true, + false + ]); + }); + }); + + describe('comprehensive filtering', () => { + it('should filter roads by class', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Motorway Filter Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'motorways', + action: 'color', + color: '#ff0000', + filter_properties: { + class: 'motorway' + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const motorwayLayer = style.layers.find((l: any) => + l.id.includes('motorway') + ); + expect(motorwayLayer).toBeTruthy(); + expect(motorwayLayer.filter).toBeTruthy(); + expect(JSON.stringify(motorwayLayer.filter)).toContain('motorway'); + }); + + it('should filter by multiple properties', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Bridge Motorways Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'motorways', + action: 'highlight', + color: '#ff0000', + filter_properties: { + class: ['motorway', 'trunk'], + structure: 'bridge' + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const layer = style.layers.find((l: any) => l.id.includes('motorway')); + expect(layer).toBeTruthy(); + + // Check filter includes both class and structure + const filterStr = JSON.stringify(layer.filter); + expect(filterStr).toContain('structure'); + expect(filterStr).toContain('bridge'); + expect(filterStr).toContain('class'); + }); + + it('should filter admin boundaries correctly', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Undisputed Countries Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'country_boundaries', + action: 'color', + color: '#0000ff', + filter_properties: { + admin_level: 0, + disputed: 'false', + maritime: 'false' + } + } + ] + }; + + const result = await tool.execute(input); + expect(result.isError).toBe(false); + + const text = result.content[0].text; + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/); + const style = JSON.parse(jsonMatch![1]); + + const layer = style.layers.find((l: any) => l.id.includes('admin')); + expect(layer).toBeTruthy(); + + const filterStr = JSON.stringify(layer.filter); + expect(filterStr).toContain('admin_level'); + expect(filterStr).toContain('0'); + expect(filterStr).toContain('disputed'); + expect(filterStr).toContain('false'); + }); + }); + + describe('multiple layers', () => { + it('should handle multiple layers with different actions', async () => { + const input: StyleBuilderToolInput = { + style_name: 'Multi Layer Test', + base_style: 'streets-v12', + layers: [ + { + layer_type: 'water', + action: 'color', + color: '#0066ff' + }, + { + layer_type: 'parks', + action: 'highlight', + color: '#00ff00' + }, + { + layer_type: 'place_labels', + action: 'hide' + }, + { + layer_type: 'buildings', + action: 'show' + } + ] + }; + + const result = await tool.execute(input); + + expect(result.isError).toBe(false); + const text = result.content[0].text; + + expect(text).toContain('Layers Configured:** 4'); + expect(text).toContain('Set to #0066ff'); + expect(text).toContain('Highlighted'); + expect(text).toContain('Hidden'); + expect(text).toContain('Shown'); + }); + }); +});