Writing a GeoTee plugin

Training guideTool ReferenceOpen GeoTee

What a plugin is

A zip containing a folder <id>/ with plugin.json (the manifest) and one or more of: run.py (Python, runs on the server with GDAL + numpy), run.php (PHP, runs inside the API with the SQL helpers), client.js (runs in the browser with the GT API). Administrators install it from Analysis › Plugins ▾ › Plugin Manager (catalogue, zip or URL). Enabled plugins appear in the Plugins menu, grouped by category, and in Command Search.

Starter examples (all in plugins/repo/*.zip on this server): graticule (simplest Python), datetime_tools (PHP, in place), coordinate_capture (client only), dem_profile (client + server), csv_wkt_import (file upload).

plugin.json

{
  "id": "graticule",                 // a-z 0-9 _ ; folder name and zip root
  "name": "Graticule / Grid Lines",
  "version": "1.0", "author": "GT Digital", "category": "Cartography",
  "description": "One or two sentences shown in the menu and manager.",
  "help": "Shown at the bottom of the dialog.",
  "client": false,                   // true when client.js registers the tool itself
  "output": "layer",                 // layer | rows | inplace | none  (button label and result handling)
  "fields": [                        // the dialog, rendered automatically
    {"id":"layer","type":"layer","label":"Extent from layer","optional":true},
    {"id":"raster","type":"layer","label":"DEM","geom":"raster"},
    {"id":"field","type":"field","label":"Value field","layerRef":"layer","numeric":true},
    {"id":"interval","type":"number","label":"Interval (°)","default":0.5},
    {"id":"mode","type":"select","label":"Mode","options":[["a","Option A"],["b","Option B"]],"default":"a"},
    {"id":"flag","type":"check","label":"Tick me"},
    {"id":"title","type":"text","label":"Title","default":""},
    {"id":"notes","type":"textarea","label":"Notes"},
    {"id":"file","type":"file","label":"CSV file","accept":".csv"}
  ]
}

run.py — the server contract

The script gets one argument: a JSON context file. Import the helper library (on the path automatically):

from geotee_plugin import ctx, load, save, done, fail, param, utm, points_of, haversine_m, bearing
c = ctx()                                   # {params, layers:{field:{path,name,geom_type,bbox,schema,raster,id}}, files:{field:path}, out_geojson, out_tif, utm_epsg}
gj = load(c['layers']['layer']['path'])     # GeoJSON dict (EPSG:4326)
iv = param('interval', 0.5, float)
# ... build features ...
save({'type':'FeatureCollection','features':feats})            # writes c['out_geojson']
done(layer={'name':'My result','geom':'LineString'})           # new layer
# other endings:
done(rows=[{'a':1,'b':2}], field='my_table')                   # table in the dialog + CSV download
done(inplace='layer', message='42 features updated')            # replace the input layer (version kept)
done(message='Nothing to do')                                   # just a message
fail('Explain what went wrong')                                 # error shown in the dialog

A raster result: write a GeoTIFF to c['out_tif'] and call done(layer={'path': c['out_tif'], 'name': 'Slope', 'ramp': 'terrain', 'maxzoom': 14}). Rasters given as inputs ("geom":"raster") arrive as GeoTIFF paths. Python has GDAL/OGR (osgeo), numpy, and the command-line tools (ogr2ogr, gdalwarp…). Print nothing else to stdout after done().

run.php

Runs inside the API with $ctx (same shape) and every helper from _vec.php: layer($id), mkgpkg(), to_gpkg(), sql_to_layer(), replace_in_place(), rows_query(), qid(), has_field(), utm_epsg(). Return an array: ['layer' => ['path'=>…,'name'=>…]], ['rows' => […]], ['inplace' => ['field'=>'layer','path'=>…]], ['done_layer' => $layerRow] (when you already used replace_in_place / sql_to_layer), or ['error' => '…'].

client.js — the browser API

GT.registerTool({ id:'my_plugin', run(manifest) {
  GT.dialog('Title', '<div class="field">…</div><div id="anmsg"></div>', [['Cancel', GT.closeDialog], ['Go', async () => {
    const d = await GT.runPlugin('my_plugin', { some:'params', points }, { file:fileObject });   // calls run.py / run.php
    GT.addLayer(d.layer);                                        // library row → map
  }, 'primary']], /*wide*/ false);
} });
GT.…What it gives
mapThe MapLibre map (GT.map.on('click', …), getCenter()…)
layers(), selectedLayer(), library()Map layers ({id, libId, name, raster, fields, geo}), the selected one, the catalogue rows
pickPoints(max, cb)Crosshair pick mode with a Done/Cancel bar; cb([[lon,lat],…])
dialog(title, html, buttons, wide), closeDialog(), toast(msg), esc(text)Standard dialog and helpers; put results in #anmsg
runPlugin(id, params, files)Runs the server part; resolves to {layer|rows|data|message}
addLayer(row), reloadCatalog(), api(url, opt), GFAdd a result to the map, refresh the catalogue, raw API access, the runtime library

A plugin with "client": true and no run.* is client-only (no server call). A plugin with both is “hybrid”: the client draws the UI, the server does the work (see dem_profile).

Packaging and sharing

Zip the folder so the zip contains my_plugin/plugin.json. Install it from the manager (zip or URL). To add it to this server's catalogue, put the zip in plugins/repo/ and add an entry to plugins/repo/catalog.json. Only admins can install; editors can run; viewers see nothing. Plugins run with the server's rights — install only code you trust.