Claude Code Plugins

Community-maintained marketplace

Feedback

>-

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name postgis-spatial
description Use when working with geographic queries, geometry filtering, ST_* functions, or region-based candidate filtering. Load for PostGIS spatial operations, polygon intersections, distance calculations, or geographic bounds. Covers ST_Intersects, ST_Contains, GiST indexes, and geometry storage patterns.

PostGIS Spatial

Geographic query patterns for place and region filtering.

Announce: "I'm using postgis-spatial to implement geographic queries correctly."

Geometry Storage

Places store their geometry as PostGIS geometry:

CREATE TABLE places (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  geom geometry(Geometry, 4326),  -- Can be Point, Polygon, or MultiPolygon
  -- SRID 4326 = WGS84 (standard lat/lng)
);

-- GiST index for spatial queries
CREATE INDEX idx_places_geom ON places USING gist (geom);

Geographic regions for questions:

CREATE TABLE geographic_regions (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  region_type text,  -- 'continent', 'country', 'subregion'
  geom geometry(MultiPolygon, 4326)
);

CREATE INDEX idx_geographic_regions_geom 
ON geographic_regions USING gist (geom);

Core Functions

ST_Intersects - Overlap Check

-- Does place intersect with region?
SELECT p.id, p.name
FROM places p
JOIN geographic_regions r ON ST_Intersects(p.geom, r.geom)
WHERE r.name = 'Europe';

ST_Contains - Fully Inside

-- Is place fully contained within region?
SELECT p.id, p.name
FROM places p
JOIN geographic_regions r ON ST_Contains(r.geom, p.geom)
WHERE r.name = 'France';

ST_DWithin - Distance Check

-- Places within 100km of a point
SELECT p.id, p.name
FROM places p
WHERE ST_DWithin(
  p.geom::geography,
  ST_Point(2.3522, 48.8566)::geography,  -- Paris
  100000  -- 100km in meters
);

Geographic Candidate Filtering

The game uses geographic questions to narrow candidates:

-- Filter candidates by answered geographic questions
CREATE FUNCTION filter_geographic_candidates(
  p_session_id uuid,
  p_include_regions uuid[],  -- Must intersect ALL of these
  p_exclude_regions uuid[]   -- Must NOT intersect ANY of these
)
RETURNS TABLE (place_id uuid) AS $$
BEGIN
  RETURN QUERY
  SELECT p.id
  FROM places p
  WHERE 
    -- Include logic: Must intersect ALL confirmed regions
    (p_include_regions IS NULL OR (
      SELECT COUNT(*) FROM unnest(p_include_regions) AS r(rid)
      JOIN geographic_regions gr ON gr.id = r.rid
      WHERE ST_Intersects(p.geom, gr.geom)
    ) = array_length(p_include_regions, 1))
    
    -- Exclude logic: Must NOT intersect ANY excluded region
    AND (p_exclude_regions IS NULL OR NOT EXISTS (
      SELECT 1 FROM unnest(p_exclude_regions) AS r(rid)
      JOIN geographic_regions gr ON gr.id = r.rid
      WHERE ST_Intersects(p.geom, gr.geom)
    ));
END;
$$ LANGUAGE plpgsql;

Logic:

  • Include = AND (must be in ALL confirmed regions)
  • Exclude = OR (must not be in ANY excluded region)

Split Quality for Questions

Geographic questions are ranked by how well they split candidates:

-- Calculate how evenly a region splits current candidates
SELECT 
  r.id,
  r.name,
  COUNT(*) FILTER (WHERE ST_Intersects(p.geom, r.geom)) AS yes_count,
  COUNT(*) FILTER (WHERE NOT ST_Intersects(p.geom, r.geom)) AS no_count,
  -- Split quality: 1.0 = perfect 50/50, 0.5 = all one side
  1.0 - ABS(0.5 - (
    COUNT(*) FILTER (WHERE ST_Intersects(p.geom, r.geom))::float / COUNT(*)
  )) AS split_quality
FROM candidate_places p
CROSS JOIN geographic_regions r
GROUP BY r.id, r.name
ORDER BY split_quality DESC;

Geometry Types

Handle different geometry types:

-- Extract centroid for any geometry type
SELECT 
  id,
  ST_X(ST_Centroid(geom)) AS lng,
  ST_Y(ST_Centroid(geom)) AS lat
FROM places;

-- Get bounding box
SELECT 
  id,
  ST_XMin(geom) AS min_lng,
  ST_YMin(geom) AS min_lat,
  ST_XMax(geom) AS max_lng,
  ST_YMax(geom) AS max_lat
FROM places;

Anti-Patterns

DON'T: Forget Geography Cast for Distance

-- WRONG: Distance in degrees (meaningless)
SELECT ST_Distance(a.geom, b.geom) FROM ...

-- CORRECT: Cast to geography for meters
SELECT ST_Distance(a.geom::geography, b.geom::geography) FROM ...

DON'T: Skip GiST Index

Without index, spatial queries do sequential scan:

-- ALWAYS create GiST index on geometry columns
CREATE INDEX idx_table_geom ON table USING gist (geom);

DON'T: Mix SRIDs

-- WRONG: Different SRIDs
ST_Intersects(geom_4326, geom_3857)

-- CORRECT: Transform to same SRID
ST_Intersects(geom_4326, ST_Transform(geom_3857, 4326))

Creating Points from Lat/Lng

-- Create point geometry from coordinates
-- NOTE: ST_Point takes (longitude, latitude) - X, Y order!
SELECT ST_SetSRID(ST_Point(longitude, latitude), 4326) AS geom;

-- WRONG: Lat/Lng order
ST_Point(48.8566, 2.3522)  -- This puts Paris in the wrong place!

-- CORRECT: Lng/Lat order
ST_Point(2.3522, 48.8566)  -- Paris

References

See references/spatial-queries.md for more examples.