Designing Line-Transect Surveys

Survey design
Line transects
Design random, replicable line-transect surveys with Rdistance
Author

Trent McDonald

Published

August 13, 2026

Modified

September 20, 2026

Note

This tutorial was rendered using Rdistance version 4.5.0.

Overview

Every distance-sampling study begins with a map and a question: where do we fly, walk, or drive, and how much of it? The answers to these questions must be random (to satisfy a key assumption of distance-sampling), efficient (so that the budget buys as much information as possible), and reproducible (so reviewers understand and can believe results). As of version 4.5.0, Rdistance includes a family of functions that turn a study-area polygon and a target amount of effort into a ready-to-survey set of line transects. The family of design functions included in Rdistance supports the following:

  • Rectangular (“back-and-forth”) and zigzag layouts,
  • On-effort versus total effort targets,
  • Single continuous routes versus individual legs geometries,
  • Random replicates,
  • Multiple polygons in a single call,
  • User-supplied baselines to adjust and customize transect directions,
  • Help taming strongly concave polygons.

The following four functions do the work:

Function Role
calcLineLength() How much transect do I need for a target sample size?
findSpacing() What transect spacing yields a specific length of transect?
makeLines() Place transects at a given spacing, with a random start.
drawTransects() Convenience wrapper: findSpacing() then makeLines().
convexPartition() Split a strongly concave polygon into convex pieces.

Most users only ever call drawTransects(). The other functions are there when you want to inspect or control the intermediate steps.

Map-drawing helper

Every design function accepts plot = TRUE for a quick base-R look at the result. The maps in this tutorial are drawn with mapview, which lets you pan, zoom, and switch basemaps. The following helper function (not a part of Rdistance) keeps the map plotting code out of the way and allows the tutorial to focus on design elements. The map helper, designMap draws the study area, the baseline, and the transects themselves.

Listing 1: A map-drawing helper function for the tutorial.
basemaps <- c("Esri.WorldImagery", "OpenStreetMap", "CartoDB.Positron")

designMap <- function(design
                      , sPoly
                      , baseline = TRUE) {
  m <- mapview(sPoly
               , col.regions = "white"
               , alpha.regions = 0.25
               , color = "white"
               , lwd = 1
               , layer.name = "Study area"
               , legend = FALSE
               , map.types = basemaps)
  if (baseline) {
    m <- m + mapview(attr(design, "summary")$polygons
                     , color = "yellow"
                     , lwd = 2
                     , layer.name = "Baseline"
                     , legend = FALSE)
  }
  m + mapview(design
              , color = "red"
              , lwd = 3
              , layer.name = "Transects"
              , legend = FALSE)
}

Example study areas

This tutorial demonstrates transect design on two study areas bundled with the package. These polygons, located at the northern end of Cook Inlet in Alaska, are two polygons (of many) in a statewide aerial survey for Aleutian terns conducted during 2023, 2024, and 2025. The example polygons are bundled in the exampleSurveyPoly object which is projected to NAD83 / Alaska Albers (EPSG:3338), an equal-area projection. Projected geometries are strongly recommended everywhere, but particularly in Alaska where a Web-Mercator map (60°N) inflates areas roughly four-fold, which distorts area-based effort calculation. Maps can be drawn in any projection, for example mapview() reprojects for display, but the design itself should be computed in an equal-area projection.

data(exampleSurveyPoly)
st_crs(exampleSurveyPoly)$Name
> [1] "NAD83 / Alaska Albers"
set_units(st_area(exampleSurveyPoly), "km^2") # ~ equal size
> Units: [km^2]
> [1] 366.6027 325.2253

mapview(exampleSurveyPoly
        , col.regions = "magenta"
        , alpha.regions = 0.25
        , layer.name = "Survey strata"
        , legend = FALSE
        , map.types = basemaps)
Figure 1: Two example polygons used in the tutorial at the northern end of Cook Inlet in Alaska. Both polygons are long, narrow, and bent. One edge is sinuous and follows the coastline, while the other edge is straight and approximately 5 km from the first edge.

How much transect?

Function calcLineLength() converts a target number of detections into a length of transect. To do this, it must have study-area size, an expected abundance N (a guess, or from a previous study), the per-group detection probability p, the strip half-width w, and the average group size. The result is the amount of on-effort transect rougly required to achieve the requested number of detected groups.

effortLen <- calcLineLength(
    sPoly        = exampleSurveyPoly[1, ]
  , N            = 12000
  , p            = 0.5
  , w            = set_units(200, "m")
  , targetGroups = 300
  , avgGroupSize = 2.5
)
effortLen
> 229126.7 [m]
effortLen <- set_units(effortLen, "km") # convert to km
effortLen
> 229.1267 [km]

We use this as a target length in the examples that follow.

Rectangular (“back-and-forth”) transects

The simplest, and most popular, transect layout consists of a set of parallel lines across the study area. drawTransects() finds the spacing that yields a target length, then drops the lines onto the polygons(s) with a random start. The input parameter targetLength must be a units length. If the study area consists of two or more polygons, total effort (targetLength) is split among polygons in proportion to area. Behind the scenes, drawTransects calls findSpacing which in turn uses an optimization algorithm to find the spacing that yields targetLength of transects in the polygons(s).

rect <- drawTransects(sPoly = exampleSurveyPoly[1, ]
                      , targetLength = effortLen
                      , type = "rectangular"
                      , angle = set_units(0, "degrees"))

designMap(rect, exampleSurveyPoly[1, ])
Figure 2: One set of rectangular transects randomly placed on the first example polygon.

The red line in Figure 2 is a single continuous route. The idea is that the plane would fly one vertical line (on-effort) across the polygon, turns at the end and fly to the next line (off-effort), fly the next line in the opposite direction (on-effort), and so on (a serpentine, or boustrophedon, path). The yellow line is the baseline used to construct the transects. The baseline is a straight line through the centroid of the polygon and transects are constructed perpendicular to the baseline. The total length of the route is as close to targetLength as possible give the shape of the polygon and the (integer) number of transects.

 data.frame(target = effortLen, realized = rect$totalLength, pctDiff = 100*abs(effortLen - rect$totalLength) / effortLen)
>          target      realized      pctDiff
> 1 229.1267 [km] 232.9585 [km] 1.672383 [1]

It is possible to rotate the whole family of transects using the angle parameter. Parameter angle must be an angle with units convertible to degrees (i.e., either “degrees” or “radians”). angle is measured clockwise from north so 0° = North–South, 90° = East–West.

rect45 <- drawTransects(sPoly = exampleSurveyPoly[1, ]
                        , targetLength = effortLen
                        , type = "rectangular"
                        , angle = set_units(45, "degrees"))

designMap(rect45, exampleSurveyPoly[1, ])
Figure 3: One set of rectangular transects randomly placed on the second example polygon, oriented 45° relative to north.
data.frame(target = effortLen, realized = rect45$totalLength, pctDiff = 100*abs(effortLen - rect45$totalLength) / effortLen)
>          target      realized      pctDiff
> 1 229.1267 [km] 231.4684 [km] 1.022004 [1]

On-effort vs. total length

Rather than target the total route length, Rdistance can target a specified length of on-effort transect which ignores transit legs. Each row of the returned sf object returned by drawTransects reports two lengths:

rect
> Simple feature collection with 1 feature and 7 fields
> Geometry type: LINESTRING
> Dimension:     XY
> Bounding box:  xmin: 155643.9 ymin: 1249122 xmax: 210296 ymax: 1268315
> Projected CRS: NAD83 / Alaska Albers
>   transectType            id polygon leg       spacing onEffortLength
> 1  rectangular Replicate0001       1   1 2.186085 [km]   167.207 [km]
>     totalLength                       geometry
> 1 232.9585 [km] LINESTRING (155643.9 125144...
  • onEffortLength is the sum of all on-effort survey lines — the lines where you actually count animals.
  • totalLength adds the off-effort transit legs — the connector legs that join one on-effort line to the next.
data.frame(onEffort = rect$onEffortLength,
           total    = rect$totalLength)
>       onEffort         total
> 1 167.207 [km] 232.9585 [km]

By default targetLength targets total length (on-effort plus off-effort). If your target is on-effort survey length only, set parameter target = "onEffort".

onEff <- drawTransects(sPoly = exampleSurveyPoly[1, ]
                       , targetLength = effortLen
                       , type = "rectangular"
                       , target = "onEffort")
onEff$onEffortLength   
> 229.0224 [km]
set_units(c(onEffort = onEff$onEffortLength,
           total    = onEff$totalLength, 
           target   = effortLen), "km")
> Units: [km]
> onEffort    total   target 
> 229.0224 294.2085 229.1267
c(pctDiffOnEffort = 100*abs(effortLen - onEff$onEffortLength) / effortLen)
> 0.04549375 [1]

Zigzag transects

A zigzag sweeps back and forth across the polygon, pivoting on its boundary. It is a single continuous line with no connectors. The idea is that the survey
never stops and there is no off-effort leg. If airplanes are being used, they cannot pivot instantly and must circle at the end of the zig-zag. Walkers or vehicles can pivot instaly.

The pivots are placed along the boundary of the polygon. Transects intersect a straight baseline through the polygon. When you do not supply a baseline, Rdistance estimates one by connecting the midpoint of many polygon cords, straightening this centerline, and translates it to pass through the polygon’s centroid. The baseline must extend past the polygon in both directions so that pivots can be placed all the way through it. Transect legs are spaced spacing apart along this baseline, and the route connects successive pivots on alternating sides.

zig <- drawTransects(exampleSurveyPoly[1, ]
                     , effortLen
                     , type = "zigzag")

designMap(zig, exampleSurveyPoly[1, ])
Figure 4: One set of zig-zag transects randomly placed on the first example polygon.

The spacing parameter has the same interpretation under both rectangular and zigzag layouts. Spacing is the the distance between adjacent transects along the baseline. For rectangular transects that is the perpendicular distance between the parallel lines. For zigzags it is the distance along the baseline between the points where adjacent legs cross it.

zig$spacing
> 1.631256 [km]

Because the route is continuous, a zigzag transect’s total is nearly all on-effort. The two distances differ only where the polygon is concave because a leg that leaves the polygon is clipped. The excursion outside the polygon counts as transit, not as on-effort survey. On this draw the route stayed inside the stratum, so the two agree; the next section shows a draw where a leg does slip outside.

c(onEffort = sum(zig$onEffortLength),
  total    = sum(zig$totalLength))
> Units: [km]
> onEffort    total 
> 227.5806 227.6570

The baseline is always straight. A strongly bent polygon is better handled by splitting it into more-convex pieces (see Taming concave polygons) than by bending the baseline around the corner.

Routes vs. legs: the combine switch

combine controls what the returned geometry represents. Both settings report the same onEffortLength and totalLength, so either one tells you the whole story; they differ in what is drawn.

With combine = TRUE (the default) you get the route as flown: one continuous line per polygon, not clipped to the study area. That is the line you hand to a pilot, and summing its length gives the total length. Where the polygon is concave the route may leave it briefly, exactly as the aircraft would.

With combine = FALSE you get one row per leg, each clipped to the study area, so nothing is drawn outside the polygon. Summing those lengths gives the on-effort length. A leg that a concavity breaks into several pieces comes back as a single MULTILINESTRING row, so there is still exactly one row per leg.

legs <- drawTransects(exampleSurveyPoly[1, ]
                      , set_units(250, "km")
                      , type = "zigzag"
                      , combine = FALSE)
nrow(legs)                                    # one row per leg
> [1] 37
table(as.character(st_geometry_type(legs)))   # split legs are MULTILINESTRINGs
> 
>      LINESTRING MULTILINESTRING 
>              33               4

c(sumOfDrawnLengths = sum(st_length(legs)),   # clipped: on-effort
  onEffort          = sum(legs$onEffortLength),
  total             = sum(legs$totalLength))
> Units: [m]
> sumOfDrawnLengths          onEffort             total 
>          255412.5          255412.5          255412.5

Compare the two on the concave stratum. The clipped legs (below) stay inside the polygon; the route in the map above does not.

designMap(legs, exampleSurveyPoly[1, ])

For rectangular transects the legs are clipped either way, and with combine = FALSE each leg’s totalLength includes the transit distance to the next leg, so the per-leg totals still sum to the route length.

Controlling the two steps

drawTransects() is just findSpacing() followed by makeLines(). Call them yourself when you want to reuse a spacing or set it by hand.

s <- findSpacing(exampleSurveyPoly[1, ]
                 , set_units(250, "km")
                 , type = "zigzag")
s                                          # the spacing that hits the target
> 1.505093 [km]

design <- makeLines(exampleSurveyPoly[1, ]
                    , type = "zigzag"
                    , spacing = s)
sum(design$onEffortLength)
> 249.9332 [km]

Pass a spacing directly to drawTransects() (or makeLines()) to skip the optimization entirely — handy when a protocol fixes the line spacing:

fixed <- makeLines(exampleSurveyPoly[1, ]
                   , type = "rectangular"
                   , spacing = set_units(5, "km"))
fixed$spacing
> 5 [km]

Random replicates

Because the start is random, every call gives a different (valid) design. Ask for several at once with R; the output stacks the replicates and labels them with an id column ("Replicate0001", "Replicate0002", …).

reps <- drawTransects(exampleSurveyPoly[1, ]
                      , set_units(250, "km")
                      , type = "zigzag"
                      , R = 10)

mapview(exampleSurveyPoly[1, ]
        , col.regions = "white"
        , alpha.regions = 0.25
        , layer.name = "Study area"
        , legend = FALSE
        , map.types = basemaps) +
  mapview(reps
          , zcol = "id"
          , lwd = 2
          , layer.name = "Replicate")

Realized lengths vary from replicate to replicate, so they are not pre-summed. Aggregate the output columns yourself — grouping by id gives the per-replicate totals and how close each came to the target:

tgt <- attr(reps, "summary")$targetLength
reps |>
  st_drop_geometry() |>
  group_by(id) |>
  summarize(onEffortLength = sum(onEffortLength),
            totalLength    = sum(totalLength),
            pctOfTarget    = as.numeric(totalLength / tgt) * 100,
            .groups = "drop")
> # A tibble: 10 × 4
>    id            onEffortLength totalLength pctOfTarget
>    <chr>                   [km]        [km]       <dbl>
>  1 Replicate0001           250.        250.        100.
>  2 Replicate0002           254.        255.        102.
>  3 Replicate0003           255.        256.        102.
>  4 Replicate0004           252.        252.        101.
>  5 Replicate0005           255.        255.        102.
>  6 Replicate0006           253.        253.        101.
>  7 Replicate0007           250.        250.        100.
>  8 Replicate0008           250.        251.        100.
>  9 Replicate0009           255.        256.        102.
> 10 Replicate0010           252.        252.        101.

Several polygons at once

Pass an sf object with more than one polygon and Rdistance designs all of them together, allocating effort in proportion to area. Rows are treated separately — polygons are not dissolved — and a MULTIPOLYGON in one row is split into its parts.

both <- drawTransects(exampleSurveyPoly
                      , set_units(400, "km")
                      , type = "zigzag")
table(both$polygon)                        # one route per polygon
> 
> 1 2 
> 1 1

designMap(both, exampleSurveyPoly)

A per-polygon summary — including each polygon’s baseline geometry, area, and solidity — rides along as an attribute:

attr(both, "summary")$polygons |>
  st_drop_geometry()
>   polygon            area  solidity
> 1       1 366.6027 [km^2] 0.6571720
> 2       2 325.2253 [km^2] 0.8005554

Supplying your own baseline

For a zigzag you can override the estimated baseline with any LINESTRING. This is useful when local knowledge (a valley axis, a shoreline, a depth contour) beats the automatic estimate. Here we lay a straight baseline tilted about 20° through the second stratum.

p2  <- exampleSurveyPoly[2, ]
ctr <- st_coordinates(st_point_on_surface(st_geometry(p2)))[1, 1:2]
d   <- c(cos(20 * pi / 180), sin(20 * pi / 180))
bb  <- st_bbox(p2)
L   <- 0.7 * sqrt((bb["xmax"] - bb["xmin"])^2 + (bb["ymax"] - bb["ymin"])^2)
myBaseline <- st_sfc(st_linestring(rbind(ctr - as.numeric(L) * d,
                                         ctr + as.numeric(L) * d)),
                     crs = st_crs(p2))

zzB <- drawTransects(p2
                     , set_units(150, "km")
                     , type = "zigzag"
                     , baseline = myBaseline)

designMap(zzB, p2)

Rdistance extends a supplied baseline past the polygon before using it, so that pivots are placed all the way through, but reports it back to you exactly as you gave it. A supplied baseline is allowed only for single-polygon inputs (each polygon needs its own).

Taming concave polygons

A single straight baseline covers a reasonably convex polygon well, but a strongly concave one (a deep bay, an L-shape) may be surveyed unevenly. drawTransects() and makeLines() warn when a polygon’s solidity (area ÷ convex-hull area) is low:

invisible(drawTransects(exampleSurveyPoly[1, ]
                        , set_units(250, "km")
                        , type = "zigzag"
                        , minSolidity = 0.9))
> Warning in makeLines(sPoly, type = type, angle = angle, spacing = spacing, :
> Polygon 1 is markedly non-convex (solidity 0.66 < 0.90); a single zigzag
> baseline may not cover it well, and coverage may be uneven. Consider splitting
> it into more-convex pieces with convexPartition(), then returning to
> drawTransects()/makeLines() with the resulting pieces. Alternatively, supply
> your own 'baseline', or set minSolidity = 0 to silence this warning.

convexPartition() implements the recommended fix: it splits a concave polygon into more-convex pieces, cutting across the “waist” the way you would by hand. Each cut is made at the sharpest remaining concavity, and cutting stops when every piece is convex enough. minPieceFrac controls how small a piece a cut is allowed to create; raising it to 0.3 says “no piece smaller than 30% of the whole”, which on this crescent-shaped stratum means a single cut at the apex and two pieces.

pieces <- convexPartition(exampleSurveyPoly[1, ]
                          , minPieceFrac = 0.3)
pieces |> st_drop_geometry()
>   piece solidity            area
> 1     1   0.8120 214994423 [m^2]
> 2     2   0.9498 151608250 [m^2]

mapview(pieces
        , zcol = "piece"
        , alpha.regions = 0.45
        , layer.name = "Piece"
        , map.types = basemaps)

Solidity climbs from 0.66 for the whole stratum to 0.81 and 0.95 for the two pieces. Feed them straight back into drawTransects() — it is just another multi-polygon input, and each piece now gets its own baseline:

onPieces <- drawTransects(pieces
                          , set_units(250, "km")
                          , type = "zigzag")
sum(onPieces$onEffortLength)
> 251.8727 [km]

designMap(onPieces, pieces)

The two baselines now follow the two halves of the crescent, and coverage is far more even than a single baseline could manage.

Two end-to-end workflows

In summary, transect design usually follows one of two workflows.

Workflow 1 — sample-size driven. Compute the on-effort length needed for a target number of detections, then design to that on-effort target.

needed <- calcLineLength(exampleSurveyPoly[1, ]
                         , N = 12000
                         , p = 0.5
                         , w = set_units(200, "m")
                         , targetGroups = 300
                         , avgGroupSize = 2.5)

survey1 <- drawTransects(exampleSurveyPoly[1, ]
                         , targetLength = needed
                         , type = "rectangular"
                         , target = "onEffort")
sum(survey1$onEffortLength)                 # matches 'needed'
> 229380.8 [m]

Workflow 2 — budget driven. Convert a dollar budget and an aircraft’s cost and speed into total flyable kilometers, then design to that total target.

costPerHr <- set_units(1000, "1/hr")        # $/hr (placeholder units)
budget    <- 4000                           # dollars
speed     <- set_units(85, "miles/hr")
flyable   <- speed * budget / costPerHr     # a length

survey2 <- drawTransects(exampleSurveyPoly[1, ]
                         , targetLength = flyable
                         , type = "rectangular"
                         , target = "total")
set_units(sum(survey2$totalLength), "km")
> 550.1493 [km]

Reproducibility and export

Set a seed before designing so the exact transects can be regenerated, and write the result to any format sf supports (GeoPackage, shapefile, GPX for a GPS or autopilot). Export with combine = TRUE when the file is for a pilot, and with combine = FALSE when it is for effort bookkeeping in a GIS:

set.seed(20260813)
survey <- drawTransects(exampleSurveyPoly, set_units(400, "km"), type = "zigzag")

st_write(survey, "aleutian_transects.gpkg", delete_dsn = TRUE)
# GPX for a handheld GPS or autopilot (reproject to lon/lat first):
st_write(st_transform(survey, 4326), "aleutian_transects.gpx",
         driver = "GPX", delete_dsn = TRUE)

Recap

  • Design in an equal-area projection (meters); map in whatever you like.
  • calcLineLength() turns a target sample size into an on-effort length.
  • drawTransects() is the one-stop wrapper; findSpacing() + makeLines() expose the two steps.
  • Choose type ("rectangular" or "zigzag"), target ("total" or "onEffort"), and combine — the route as flown (unclipped, sums to total), or one clipped row per leg (sums to on-effort).
  • spacing is the distance between adjacent transects under both layouts; for a zigzag, one full cycle covers twice that.
  • Use R for replicate designs, pass a whole multi-polygon sf to design several strata at once, and supply your own baseline when you know better than the automatic estimate.
  • When a polygon is strongly concave, split it with convexPartition() and design on the pieces.

Full documentation is in the package help (?drawTransects) and on the Rdistance website.