diff --git a/package-lock.json b/package-lock.json
index d79a2239..ce040a75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
         "random": "^4.1.0"
       },
       "devDependencies": {
-        "@interacta/css-labels": "^0.1.2",
+        "@interacta/css-labels": "^0.6.0",
         "@jls-digital/storybook-addon-code": "^1.0.4",
         "@rollup/plugin-alias": "^3.1.9",
         "@rollup/plugin-commonjs": "^22.0.1",
@@ -776,11 +776,10 @@
       "license": "BSD-3-Clause"
     },
     "node_modules/@interacta/css-labels": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.1.2.tgz",
-      "integrity": "sha512-BYEU8LDKevrua77e6eruGrJ32JMi9s+OOmzPq8GEUPAU6wMQCiUk0Sebg77VAtFZ3XARMQR5kXRhKMfdt3HTKg==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.6.0.tgz",
+      "integrity": "sha512-wY+UX6A1BP7MazIUkDl5rnLDETU0lhHGQsJ/Igf9BjH7ygGKvQ05348sCLbbJmsLMPoGD1P02WnTd8enYNZuHg==",
       "dev": true,
-      "hasInstallScript": true,
       "license": "MIT"
     },
     "node_modules/@jls-digital/storybook-addon-code": {
@@ -11275,9 +11274,9 @@
       "dev": true
     },
     "@interacta/css-labels": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.1.2.tgz",
-      "integrity": "sha512-BYEU8LDKevrua77e6eruGrJ32JMi9s+OOmzPq8GEUPAU6wMQCiUk0Sebg77VAtFZ3XARMQR5kXRhKMfdt3HTKg==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.6.0.tgz",
+      "integrity": "sha512-wY+UX6A1BP7MazIUkDl5rnLDETU0lhHGQsJ/Igf9BjH7ygGKvQ05348sCLbbJmsLMPoGD1P02WnTd8enYNZuHg==",
       "dev": true
     },
     "@jls-digital/storybook-addon-code": {
diff --git a/package.json b/package.json
index 6cdf6209..8146486e 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
   "homepage": "https://cosmosgl.github.io/graph",
   "author": "cosmos.gl",
   "devDependencies": {
-    "@interacta/css-labels": "^0.1.2",
+    "@interacta/css-labels": "^0.6.0",
     "@jls-digital/storybook-addon-code": "^1.0.4",
     "@rollup/plugin-alias": "^3.1.9",
     "@rollup/plugin-commonjs": "^22.0.1",
diff --git a/src/config.ts b/src/config.ts
index ac1d4780..278be33b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -635,6 +635,12 @@ export interface GraphConfigInterface {
    * Default value: `150`
   */
   pointSamplingDistance?: number;
+  /**
+   * Link sampling distance in pixels between neighboring links when calling the `getSampledLinks` method.
+   * This parameter determines how many links will be included in the sample (based on link midpoints in screen space).
+   * Default value: `150`
+   */
+  linkSamplingDistance?: number;
   /**
    * Controls automatic position adjustment of points in the visible space.
    *
@@ -773,6 +779,7 @@ export class GraphConfig implements GraphConfigInterface {
 
   public randomSeed = undefined
   public pointSamplingDistance = defaultConfigValues.pointSamplingDistance
+  public linkSamplingDistance = defaultConfigValues.linkSamplingDistance
   public attribution = defaultConfigValues.attribution
   public rescalePositions = defaultConfigValues.rescalePositions
 
diff --git a/src/index.ts b/src/index.ts
index 45b1dc6f..f7c1ca34 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1209,6 +1209,28 @@ export class Graph {
     return this.points.getSampledPoints()
   }
 
+  /**
+   * For the links that are currently visible on the screen, get a sample of link indices with their midpoint coordinates and angle.
+   * The resulting number of links will depend on the `linkSamplingDistance` configuration property,
+   * and the sampled links will be evenly distributed (one link per grid cell, based on link midpoint in screen space).
+   * Each value is [x, y, angle]: position in data space; angle in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation).
+   */
+  public getSampledLinkPositionsMap (): Map<number, [number, number, number]> {
+    if (this._isDestroyed || !this.lines) return new Map()
+    return this.lines.getSampledLinkPositionsMap()
+  }
+
+  /**
+   * For the links that are currently visible on the screen, get a sample of link indices, midpoint positions, and angles.
+   * The resulting number of links will depend on the `linkSamplingDistance` configuration property,
+   * and the sampled links will be evenly distributed.
+   * Positions are in data space; angles are in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation).
+   */
+  public getSampledLinks (): { indices: number[]; positions: number[]; angles: number[] } {
+    if (this._isDestroyed || !this.lines) return { indices: [], positions: [], angles: [] }
+    return this.lines.getSampledLinks()
+  }
+
   /**
    * Gets the X-axis of rescaling function.
    *
@@ -1819,6 +1841,7 @@ export class Graph {
       this.canvasD3Selection
         ?.call(this.zoomInstance.behavior.transform, this.zoomInstance.getTransform([centerPosition], k))
       this.points?.updateSampledPointsGrid()
+      this.lines?.updateSampledLinksGrid()
       // Only update link index FBO if link hovering is enabled
       if (this.store.isLinkHoveringEnabled) {
         this.lines?.updateLinkIndexFbo()
diff --git a/src/modules/Lines/conic-curve-module.ts b/src/modules/Lines/conic-curve-module.ts
new file mode 100644
index 00000000..7ad70dc5
--- /dev/null
+++ b/src/modules/Lines/conic-curve-module.ts
@@ -0,0 +1,18 @@
+import type { ShaderModule } from '@luma.gl/shadertools'
+
+/**
+ * Shared GLSL for conic parametric curve (rational quadratic Bezier).
+ * Used by draw-curve-line.vert and fill-sampled-links.vert.
+ */
+const conicParametricCurveVS = /* glsl */ `
+vec2 conicParametricCurve(vec2 A, vec2 B, vec2 ControlPoint, float t, float w) {
+  vec2 divident = (1.0 - t) * (1.0 - t) * A + 2.0 * (1.0 - t) * t * w * ControlPoint + t * t * B;
+  float divisor = (1.0 - t) * (1.0 - t) + 2.0 * (1.0 - t) * t * w + t * t;
+  return divident / divisor;
+}
+`
+
+export const conicParametricCurveModule: ShaderModule = {
+  name: 'conicParametricCurve',
+  vs: conicParametricCurveVS,
+}
diff --git a/src/modules/Lines/draw-curve-line.vert b/src/modules/Lines/draw-curve-line.vert
index 75080270..96d71c86 100644
--- a/src/modules/Lines/draw-curve-line.vert
+++ b/src/modules/Lines/draw-curve-line.vert
@@ -89,12 +89,6 @@ float map(float value, float min1, float max1, float min2, float max2) {
   return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
 }
 
-vec2 conicParametricCurve(vec2 A, vec2 B, vec2 ControlPoint, float t, float w) {
-  vec2 divident = (1.0 - t) * (1.0 - t) * A + 2.0 * (1.0 - t) * t * w * ControlPoint + t * t * B;
-  float divisor = (1.0 - t) * (1.0 - t) + 2.0 * (1.0 - t) * t * w + t * t;
-  return divident / divisor;
-}
-
 float calculateLinkWidth(float width) {
   float linkWidth;
   if (scaleLinksOnZoom > 0.0) {
diff --git a/src/modules/Lines/fill-sampled-links.frag b/src/modules/Lines/fill-sampled-links.frag
new file mode 100644
index 00000000..657aa900
--- /dev/null
+++ b/src/modules/Lines/fill-sampled-links.frag
@@ -0,0 +1,12 @@
+#version 300 es
+#ifdef GL_ES
+precision highp float;
+#endif
+
+in vec4 rgba;
+
+out vec4 fragColor;
+
+void main() {
+  fragColor = rgba;
+}
diff --git a/src/modules/Lines/fill-sampled-links.vert b/src/modules/Lines/fill-sampled-links.vert
new file mode 100644
index 00000000..669aee32
--- /dev/null
+++ b/src/modules/Lines/fill-sampled-links.vert
@@ -0,0 +1,81 @@
+#version 300 es
+#ifdef GL_ES
+precision highp float;
+#endif
+
+in vec2 pointA;
+in vec2 pointB;
+in float linkIndices;
+
+uniform sampler2D positionsTexture;
+
+#ifdef USE_UNIFORM_BUFFERS
+layout(std140) uniform fillSampledLinksUniforms {
+  float pointsTextureSize;
+  mat4 transformationMatrix;
+  float spaceSize;
+  vec2 screenSize;
+  float curvedWeight;
+  float curvedLinkControlPointDistance;
+  float curvedLinkSegments;
+} fillSampledLinks;
+
+#define pointsTextureSize fillSampledLinks.pointsTextureSize
+#define transformationMatrix fillSampledLinks.transformationMatrix
+#define spaceSize fillSampledLinks.spaceSize
+#define screenSize fillSampledLinks.screenSize
+#define curvedWeight fillSampledLinks.curvedWeight
+#define curvedLinkControlPointDistance fillSampledLinks.curvedLinkControlPointDistance
+#define curvedLinkSegments fillSampledLinks.curvedLinkSegments
+#else
+uniform float pointsTextureSize;
+uniform float spaceSize;
+uniform vec2 screenSize;
+uniform float curvedWeight;
+uniform float curvedLinkControlPointDistance;
+uniform float curvedLinkSegments;
+uniform mat3 transformationMatrix;
+#endif
+
+out vec4 rgba;
+
+void main() {
+  vec4 posA = texture(positionsTexture, (pointA + 0.5) / pointsTextureSize);
+  vec4 posB = texture(positionsTexture, (pointB + 0.5) / pointsTextureSize);
+  vec2 a = posA.rg;
+  vec2 b = posB.rg;
+
+  vec2 tangent = b - a;
+  float angle = -atan(tangent.y, tangent.x);
+
+  vec2 mid;
+  if (curvedLinkSegments <= 1.0) {
+    mid = (a + b) * 0.5;
+  } else if (curvedLinkControlPointDistance != 0.0 && curvedWeight != 0.0) {
+    vec2 xBasis = b - a;
+    vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x));
+    float linkDist = length(xBasis);
+    float h = curvedLinkControlPointDistance;
+    vec2 controlPoint = (a + b) / 2.0 + yBasis * linkDist * h;
+    mid = conicParametricCurve(a, b, controlPoint, 0.5, curvedWeight);
+  } else {
+    mid = (a + b) * 0.5;
+  }
+
+  vec2 p = 2.0 * mid / spaceSize - 1.0;
+  p *= spaceSize / screenSize;
+  #ifdef USE_UNIFORM_BUFFERS
+  mat3 transformMat3 = mat3(transformationMatrix);
+  vec3 final = transformMat3 * vec3(p, 1);
+  #else
+  vec3 final = transformationMatrix * vec3(p, 1);
+  #endif
+
+  vec2 pointScreenPosition = (final.xy + 1.0) * screenSize / 2.0;
+  rgba = vec4(linkIndices, mid.x, mid.y, angle);
+  float i = (pointScreenPosition.x + 0.5) / screenSize.x;
+  float j = (pointScreenPosition.y + 0.5) / screenSize.y;
+  gl_Position = vec4(2.0 * vec2(i, j) - 1.0, 0.0, 1.0);
+
+  gl_PointSize = 1.0;
+}
diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts
index 9f9adbbe..877aeba0 100644
--- a/src/modules/Lines/index.ts
+++ b/src/modules/Lines/index.ts
@@ -2,20 +2,26 @@ import { Framebuffer, Buffer, Texture, UniformStore, RenderPass } from '@luma.gl
 import { Model } from '@luma.gl/engine'
 import { CoreModule } from '@/graph/modules/core-module'
 import type { Mat4Array } from '@/graph/modules/Store'
+import { conicParametricCurveModule } from '@/graph/modules/Lines/conic-curve-module'
 import drawLineFrag from '@/graph/modules/Lines/draw-curve-line.frag?raw'
 import drawLineVert from '@/graph/modules/Lines/draw-curve-line.vert?raw'
+import fillGridWithSampledLinksFrag from '@/graph/modules/Lines/fill-sampled-links.frag?raw'
+import fillGridWithSampledLinksVert from '@/graph/modules/Lines/fill-sampled-links.vert?raw'
 import hoveredLineIndexFrag from '@/graph/modules/Lines/hovered-line-index.frag?raw'
 import hoveredLineIndexVert from '@/graph/modules/Lines/hovered-line-index.vert?raw'
 import { defaultConfigValues } from '@/graph/variables'
 import { getCurveLineGeometry } from '@/graph/modules/Lines/geometry'
 import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils'
 import { ensureVec2, ensureVec4 } from '@/graph/modules/Shared/uniform-utils'
+import { readPixels } from '@/graph/helper'
 
 export class Lines extends CoreModule {
   public linkIndexFbo: Framebuffer | undefined
   public hoveredLineIndexFbo: Framebuffer | undefined
+  public sampledLinksFbo: Framebuffer | undefined
   private drawCurveCommand: Model | undefined
   private hoveredLineIndexCommand: Model | undefined
+  private fillSampledLinksFboCommand: Model | undefined
   private pointABuffer: Buffer | undefined
   private pointBBuffer: Buffer | undefined
   private colorBuffer: Buffer | undefined
@@ -27,6 +33,17 @@ export class Lines extends CoreModule {
   private quadBuffer: Buffer | undefined
   private linkIndexTexture: Texture | undefined
   private hoveredLineIndexTexture: Texture | undefined
+  private fillSampledLinksUniformStore: UniformStore<{
+    fillSampledLinksUniforms: {
+      pointsTextureSize: number;
+      transformationMatrix: Mat4Array;
+      spaceSize: number;
+      screenSize: [number, number];
+      curvedWeight: number;
+      curvedLinkControlPointDistance: number;
+      curvedLinkSegments: number;
+    };
+  }> | undefined
 
   // Uniform stores for scalar uniforms
   private drawLineUniformStore: UniformStore<{
@@ -67,7 +84,7 @@ export class Lines extends CoreModule {
   private previousScreenSize: [number, number] | undefined
 
   public initPrograms (): void {
-    const { device, config, store } = this
+    const { device, config, store, data } = this
 
     this.updateLinkIndexFbo()
 
@@ -176,6 +193,7 @@ export class Lines extends CoreModule {
     this.drawCurveCommand ||= new Model(device, {
       vs: drawLineVert,
       fs: drawLineFrag,
+      modules: [conicParametricCurveModule],
       topology: 'triangle-strip',
       vertexCount: this.curveLineGeometry?.length ?? 0,
       attributes: {
@@ -269,6 +287,61 @@ export class Lines extends CoreModule {
         // All texture bindings will be set dynamically in findHoveredLine() method
       },
     })
+
+    // Sampled links (for getSampledLinks / getSampledLinkPositionsMap)
+    this.fillSampledLinksUniformStore ||= new UniformStore({
+      fillSampledLinksUniforms: {
+        uniformTypes: {
+          pointsTextureSize: 'f32',
+          transformationMatrix: 'mat4x4<f32>',
+          spaceSize: 'f32',
+          screenSize: 'vec2<f32>',
+          curvedWeight: 'f32',
+          curvedLinkControlPointDistance: 'f32',
+          curvedLinkSegments: 'f32',
+        },
+        defaultUniforms: {
+          pointsTextureSize: store.pointsTextureSize ?? 0,
+          transformationMatrix: store.transformationMatrix4x4,
+          spaceSize: store.adjustedSpaceSize ?? 0,
+          screenSize: ensureVec2(store.screenSize, [0, 0]),
+          curvedWeight: config.curvedLinkWeight ?? 0,
+          curvedLinkControlPointDistance: config.curvedLinkControlPointDistance ?? 0,
+          curvedLinkSegments: config.curvedLinks ? config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1,
+        },
+      },
+    })
+
+    this.fillSampledLinksFboCommand ||= new Model(device, {
+      fs: fillGridWithSampledLinksFrag,
+      vs: fillGridWithSampledLinksVert,
+      modules: [conicParametricCurveModule],
+      topology: 'point-list',
+      vertexCount: data.linksNumber ?? 0,
+      attributes: {
+        ...(this.pointABuffer && { pointA: this.pointABuffer }),
+        ...(this.pointBBuffer && { pointB: this.pointBBuffer }),
+        ...(this.linkIndexBuffer && { linkIndices: this.linkIndexBuffer }),
+      },
+      bufferLayout: [
+        { name: 'pointA', format: 'float32x2' },
+        { name: 'pointB', format: 'float32x2' },
+        { name: 'linkIndices', format: 'float32' },
+      ],
+      defines: {
+        USE_UNIFORM_BUFFERS: true,
+      },
+      bindings: {
+        fillSampledLinksUniforms: this.fillSampledLinksUniformStore.getManagedUniformBuffer(device, 'fillSampledLinksUniforms'),
+      },
+      parameters: {
+        depthWriteEnabled: false,
+        depthCompare: 'always',
+        blend: false,
+      },
+    })
+
+    this.updateSampledLinksGrid()
   }
 
   public draw (renderPass: RenderPass): void {
@@ -377,6 +450,26 @@ export class Lines extends CoreModule {
     }
   }
 
+  public updateSampledLinksGrid (): void {
+    const { store: { screenSize }, config: { linkSamplingDistance }, device } = this
+    let dist = linkSamplingDistance ?? Math.min(...screenSize) / 2
+    if (dist === 0) dist = defaultConfigValues.linkSamplingDistance
+    const w = Math.ceil(screenSize[0] / dist)
+    const h = Math.ceil(screenSize[1] / dist)
+    if (w === 0 || h === 0) return
+
+    if (!this.sampledLinksFbo || this.sampledLinksFbo.width !== w || this.sampledLinksFbo.height !== h) {
+      if (this.sampledLinksFbo && !this.sampledLinksFbo.destroyed) {
+        this.sampledLinksFbo.destroy()
+      }
+      this.sampledLinksFbo = device.createFramebuffer({
+        width: w,
+        height: h,
+        colorAttachments: ['rgba32float'],
+      })
+    }
+  }
+
   public updatePointsBuffer (): void {
     const { device, data, store } = this
     if (data.linksNumber === undefined || data.links === undefined) return
@@ -450,6 +543,15 @@ export class Lines extends CoreModule {
         linkIndices: this.linkIndexBuffer,
       })
     }
+    if (this.fillSampledLinksFboCommand) {
+      this.fillSampledLinksFboCommand.setAttributes({
+        pointA: this.pointABuffer,
+        pointB: this.pointBBuffer,
+        linkIndices: this.linkIndexBuffer,
+      })
+    }
+
+    this.updateSampledLinksGrid()
   }
 
   public updateColor (): void {
@@ -584,6 +686,100 @@ export class Lines extends CoreModule {
     }
   }
 
+  public getSampledLinkPositionsMap (): Map<number, [number, number, number]> {
+    const positions = new Map<number, [number, number, number]>()
+    if (!this.sampledLinksFbo || this.sampledLinksFbo.destroyed) return positions
+    const points = this.points
+    if (!points?.currentPositionTexture || points.currentPositionTexture.destroyed) return positions
+
+    if (this.fillSampledLinksFboCommand && this.fillSampledLinksUniformStore && this.sampledLinksFbo) {
+      this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0)
+      this.fillSampledLinksUniformStore.setUniforms({
+        fillSampledLinksUniforms: {
+          pointsTextureSize: this.store.pointsTextureSize ?? 0,
+          transformationMatrix: this.store.transformationMatrix4x4,
+          spaceSize: this.store.adjustedSpaceSize ?? 0,
+          screenSize: ensureVec2(this.store.screenSize, [0, 0]),
+          curvedWeight: this.config.curvedLinkWeight ?? 0,
+          curvedLinkControlPointDistance: this.config.curvedLinkControlPointDistance ?? 0,
+          curvedLinkSegments: this.config.curvedLinks ? this.config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1,
+        },
+      })
+      this.fillSampledLinksFboCommand.setBindings({
+        positionsTexture: points.currentPositionTexture,
+      })
+
+      const fillPass = this.device.beginRenderPass({
+        framebuffer: this.sampledLinksFbo,
+        clearColor: [-1, -1, -1, -1],
+      })
+      this.fillSampledLinksFboCommand.draw(fillPass)
+      fillPass.end()
+    }
+
+    const pixels = readPixels(this.device, this.sampledLinksFbo)
+    for (let i = 0; i < pixels.length / 4; i++) {
+      const index = pixels[i * 4]
+      const x = pixels[i * 4 + 1]
+      const y = pixels[i * 4 + 2]
+      const angle = pixels[i * 4 + 3]
+
+      if (index !== undefined && index >= 0 && x !== undefined && y !== undefined && angle !== undefined) {
+        positions.set(Math.round(index), [x, y, angle])
+      }
+    }
+    return positions
+  }
+
+  public getSampledLinks (): { indices: number[]; positions: number[]; angles: number[] } {
+    const indices: number[] = []
+    const positions: number[] = []
+    const angles: number[] = []
+    if (!this.sampledLinksFbo || this.sampledLinksFbo.destroyed) return { indices, positions, angles }
+    const points = this.points
+    if (!points?.currentPositionTexture || points.currentPositionTexture.destroyed) return { indices, positions, angles }
+
+    if (this.fillSampledLinksFboCommand && this.fillSampledLinksUniformStore && this.sampledLinksFbo) {
+      this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0)
+      this.fillSampledLinksUniformStore.setUniforms({
+        fillSampledLinksUniforms: {
+          pointsTextureSize: this.store.pointsTextureSize ?? 0,
+          transformationMatrix: this.store.transformationMatrix4x4,
+          spaceSize: this.store.adjustedSpaceSize ?? 0,
+          screenSize: ensureVec2(this.store.screenSize, [0, 0]),
+          curvedWeight: this.config.curvedLinkWeight ?? 0,
+          curvedLinkControlPointDistance: this.config.curvedLinkControlPointDistance ?? 0,
+          curvedLinkSegments: this.config.curvedLinks ? this.config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1,
+        },
+      })
+      this.fillSampledLinksFboCommand.setBindings({
+        positionsTexture: points.currentPositionTexture,
+      })
+
+      const fillPass = this.device.beginRenderPass({
+        framebuffer: this.sampledLinksFbo,
+        clearColor: [-1, -1, -1, -1],
+      })
+      this.fillSampledLinksFboCommand.draw(fillPass)
+      fillPass.end()
+    }
+
+    const pixels = readPixels(this.device, this.sampledLinksFbo)
+    for (let i = 0; i < pixels.length / 4; i++) {
+      const index = pixels[i * 4]
+      const x = pixels[i * 4 + 1]
+      const y = pixels[i * 4 + 2]
+      const angle = pixels[i * 4 + 3]
+
+      if (index !== undefined && index >= 0 && x !== undefined && y !== undefined && angle !== undefined) {
+        indices.push(Math.round(index))
+        positions.push(x, y)
+        angles.push(angle)
+      }
+    }
+    return { indices, positions, angles }
+  }
+
   public findHoveredLine (): void {
     const { config, points, store } = this
     if (!points) return
@@ -670,12 +866,18 @@ export class Lines extends CoreModule {
     this.drawCurveCommand = undefined
     this.hoveredLineIndexCommand?.destroy()
     this.hoveredLineIndexCommand = undefined
+    this.fillSampledLinksFboCommand?.destroy()
+    this.fillSampledLinksFboCommand = undefined
 
     // 2. Destroy Framebuffers (before textures they reference)
     if (this.linkIndexFbo && !this.linkIndexFbo.destroyed) {
       this.linkIndexFbo.destroy()
     }
     this.linkIndexFbo = undefined
+    if (this.sampledLinksFbo && !this.sampledLinksFbo.destroyed) {
+      this.sampledLinksFbo.destroy()
+    }
+    this.sampledLinksFbo = undefined
     if (this.hoveredLineIndexFbo && !this.hoveredLineIndexFbo.destroyed) {
       this.hoveredLineIndexFbo.destroy()
     }
@@ -696,6 +898,8 @@ export class Lines extends CoreModule {
     this.drawLineUniformStore = undefined
     this.hoveredLineIndexUniformStore?.destroy()
     this.hoveredLineIndexUniformStore = undefined
+    this.fillSampledLinksUniformStore?.destroy()
+    this.fillSampledLinksUniformStore = undefined
 
     // 5. Destroy Buffers (passed via attributes - NOT owned by Models, must destroy manually)
     if (this.pointABuffer && !this.pointABuffer.destroyed) {
diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts
index 3988c0c0..9e14baa4 100644
--- a/src/modules/Points/index.ts
+++ b/src/modules/Points/index.ts
@@ -1306,6 +1306,7 @@ export class Points extends CoreModule {
     if (dist === 0) dist = defaultConfigValues.pointSamplingDistance
     const w = Math.ceil(screenSize[0] / dist)
     const h = Math.ceil(screenSize[1] / dist)
+    if (w === 0 || h === 0) return
 
     if (!this.sampledPointsFbo || this.sampledPointsFbo.width !== w || this.sampledPointsFbo.height !== h) {
       if (this.sampledPointsFbo && !this.sampledPointsFbo.destroyed) {
diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx
index 16abb120..7c1a48f2 100644
--- a/src/stories/2. configuration.mdx	
+++ b/src/stories/2. configuration.mdx	
@@ -57,7 +57,8 @@ import { Meta } from "@storybook/blocks";
 | fitViewByPointsInRect | When `fitViewOnInit` is set to `true`, fits the view to show the points within a rectangle defined by its two corner coordinates `[[left, bottom], [right, top]]` in the scene space | `undefined` |
 | fitViewByPointIndices | When `fitViewOnInit` is set to `true`, fits the view to show only the specified points by their indices. Takes precedence over `fitViewByPointsInRect` when both are provided. | `undefined` |
 | randomSeed | Providing a value allows control over layout randomness for consistency across simulations. Applied only on initialization | `undefined` |
-| pointSamplingDistance | Sampling density for point position methods (used in `getSampledPointPositionsMap`) in pixels | `150` |
+| pointSamplingDistance | Sampling density for point position methods (used in `getSampledPointPositionsMap`) in pixels | `100` |
+| linkSamplingDistance | Sampling density for link position methods (used in `getSampledLinks` and `getSampledLinkPositionsMap`) in pixels | `100` |
 | attribution | Controls the text shown in the bottom right corner. Provide HTML content as a string for custom attribution. Empty string hides attribution | `''` |
 | rescalePositions | Control automatic point position adjustment. When undefined: auto-rescale if simulation disabled | `undefined` |
 
diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx
index 071a84a6..2fe2002d 100644
--- a/src/stories/3. api-reference.mdx	
+++ b/src/stories/3. api-reference.mdx	
@@ -533,6 +533,37 @@ The sample aims to distribute points evenly across the visible area.
   - **`indices`**: Array of point indices for the sampled points
   - **`positions`**: Flat array of coordinates in the format `[x1, y1, x2, y2, ..., xn, yn]`, where the coordinates correspond to the points at the same index positions in the `indices` array.
 
+### <a name="get_sampled_link_positions_map" href="#get_sampled_link_positions_map">#</a> graph.<b>getSampledLinkPositionsMap</b>()
+
+This method provides a sampling of link positions from the links that are currently visible on the screen. It returns a `Map` from link index to a tuple of midpoint coordinates and angle: `[x, y, angle]`. Positions are in data space; angle is in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation).
+
+The number of sampled links is determined by the `linkSamplingDistance` [configuration property](../?path=/docs/configuration--docs). This property controls the density of the sampled links:
+* A higher value for `linkSamplingDistance` results in fewer sampled links.
+* A lower value results in more sampled links.
+
+The sample distributes links evenly (one link per grid cell, based on link midpoint in screen space).
+
+**Returns:**
+
+* A `Map<number, [number, number, number]>` where each key is a link index and each value is `[x, y, angle]` (midpoint in data space, angle in radians).
+
+### <a name="get_sampled_links" href="#get_sampled_links">#</a> graph.<b>getSampledLinks</b>()
+
+This method provides an optimized way to retrieve link indices, midpoint positions, and angles for links that are currently visible on the screen. Unlike `getSampledLinkPositionsMap`, this method returns the data as arrays rather than a Map.
+
+The number of sampled links is determined by the `linkSamplingDistance` [configuration property](../?path=/docs/configuration--docs). This property controls the density of the sampled links:
+* A higher value for `linkSamplingDistance` results in fewer sampled links.
+* A lower value results in more sampled links.
+
+The sample distributes links evenly across the visible area.
+
+**Returns:**
+
+* An object containing:
+  - **`indices`**: Array of link indices for the sampled links
+  - **`positions`**: Flat array of coordinates in the format `[x1, y1, x2, y2, ..., xn, yn]`, where each pair is the midpoint of the link at the same index in `indices` (in data space)
+  - **`angles`**: Array of angles in radians for screen-space rotation (0 = right, positive = clockwise), one per sampled link
+
 ### <a name="start" href="#start">#</a> graph.<b>start</b>([<i>alpha</i>])
 
 Starts the simulation. This method only controls the simulation state, not rendering. Rendering is started automatically by `render()`.
diff --git a/src/stories/beginners.stories.ts b/src/stories/beginners.stories.ts
index d4c86c99..97d54120 100644
--- a/src/stories/beginners.stories.ts
+++ b/src/stories/beginners.stories.ts
@@ -7,6 +7,7 @@ import { basicSetUp } from './beginners/basic-set-up'
 import { pointLabels } from './beginners/point-labels'
 import { removePoints } from './beginners/remove-points'
 import { linkHovering } from './beginners/link-hovering'
+import { linkSampling } from './beginners/link-sampling'
 import { pinnedPoints } from './beginners/pinned-points'
 
 import quickStartStoryRaw from './beginners/quick-start?raw'
@@ -24,6 +25,10 @@ import removePointsStoryDataGenRaw from './beginners/remove-points/data-gen.ts?r
 import linkHoveringStoryRaw from './beginners/link-hovering/index?raw'
 import linkHoveringStoryDataGenRaw from './beginners/link-hovering/data-generator.ts?raw'
 import linkHoveringStoryCssRaw from './beginners/link-hovering/style.css?raw'
+import linkSamplingStoryRaw from './beginners/link-sampling/index?raw'
+import linkSamplingStoryDataRaw from './beginners/link-sampling/data.ts?raw'
+import linkSamplingStoryLabelsRaw from './beginners/link-sampling/labels.ts?raw'
+import linkSamplingStoryCssRaw from './beginners/link-sampling/style.css?raw'
 import pinnedPointsStoryRaw from './beginners/pinned-points/index?raw'
 import pinnedPointsStoryDataGenRaw from './beginners/pinned-points/data-gen.ts?raw'
 
@@ -128,6 +133,19 @@ export const LinkHovering: Story = {
   },
 }
 
+export const LinkSampling: Story = {
+  ...createStory(linkSampling),
+  name: 'Link Sampling',
+  parameters: {
+    sourceCode: [
+      { name: 'Story', code: linkSamplingStoryRaw },
+      { name: 'labels.ts', code: linkSamplingStoryLabelsRaw },
+      { name: 'data.ts', code: linkSamplingStoryDataRaw },
+      { name: 'style.css', code: linkSamplingStoryCssRaw },
+    ],
+  },
+}
+
 export const PinnedPoints: Story = {
   ...createStory(pinnedPoints),
   name: 'Pinned Points',
diff --git a/src/stories/beginners/link-sampling/data.ts b/src/stories/beginners/link-sampling/data.ts
new file mode 100644
index 00000000..1919d3a4
--- /dev/null
+++ b/src/stories/beginners/link-sampling/data.ts
@@ -0,0 +1,65 @@
+// Note: This is vibe coding only - quick prototype code for demonstration purposes
+
+const RADIUS = 90
+const N = 14
+
+function hslToRgb (h: number, s: number, l: number): [number, number, number] {
+  const c = (1 - Math.abs(2 * l - 1)) * s
+  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
+  const m = l - c / 2
+  let r = 0
+  let g = 0
+  let b = 0
+  if (h < 60) {
+    r = c; g = x; b = 0
+  } else if (h < 120) {
+    r = x; g = c; b = 0
+  } else if (h < 180) {
+    r = 0; g = c; b = x
+  } else if (h < 240) {
+    r = 0; g = x; b = c
+  } else if (h < 300) {
+    r = x; g = 0; b = c
+  } else {
+    r = c; g = 0; b = x
+  }
+  return [r + m, g + m, b + m]
+}
+
+export function generateLinkSamplingDemoData (): {
+  pointPositions: Float32Array;
+  links: Float32Array;
+  linkColors: Float32Array;
+  } {
+  const pointPositions = new Float32Array(N * 2)
+  for (let i = 0; i < N; i++) {
+    const angle = (i / N) * Math.PI * 2 - Math.PI / 2
+    pointPositions[i * 2] = Math.cos(angle) * RADIUS
+    pointPositions[i * 2 + 1] = Math.sin(angle) * RADIUS
+  }
+
+  const connections: [number, number][] = []
+  for (let i = 0; i < N; i++) {
+    connections.push([i, (i + 1) % N])
+  }
+  connections.push([0, 5], [2, 7], [4, 9], [6, 11], [1, 8], [3, 10])
+
+  const links = new Float32Array(connections.length * 2)
+  const linkColors = new Float32Array(connections.length * 4)
+
+  connections.forEach(([s, t], i) => {
+    links[i * 2] = s
+    links[i * 2 + 1] = t
+    const [r, g, b] = hslToRgb((i / Math.max(1, connections.length)) * 320, 0.5, 0.58)
+    linkColors[i * 4] = r
+    linkColors[i * 4 + 1] = g
+    linkColors[i * 4 + 2] = b
+    linkColors[i * 4 + 3] = 1
+  })
+
+  return {
+    pointPositions,
+    links,
+    linkColors,
+  }
+}
diff --git a/src/stories/beginners/link-sampling/index.ts b/src/stories/beginners/link-sampling/index.ts
new file mode 100644
index 00000000..eebde72a
--- /dev/null
+++ b/src/stories/beginners/link-sampling/index.ts
@@ -0,0 +1,53 @@
+import { Graph, GraphConfigInterface } from '@cosmos.gl/graph'
+import { generateLinkSamplingDemoData } from './data'
+import { LinkSamplingLabels } from './labels'
+import './style.css'
+
+export const linkSampling = (): { div: HTMLDivElement; graph: Graph; destroy?: () => void } => {
+  const data = generateLinkSamplingDemoData()
+
+  const div = document.createElement('div')
+  div.className = 'link-sampling-demo'
+  div.style.height = '100vh'
+  div.style.width = '100%'
+  div.style.position = 'relative'
+
+  const graphDiv = document.createElement('div')
+  graphDiv.style.width = '100%'
+  graphDiv.style.height = '100%'
+  div.appendChild(graphDiv)
+
+  const labelsContainer = document.createElement('div')
+  labelsContainer.className = 'link-sampling-labels'
+  div.appendChild(labelsContainer)
+
+  const linkLabels = new LinkSamplingLabels(labelsContainer)
+
+  const config: GraphConfigInterface = {
+    backgroundColor: '#252830',
+    pointDefaultColor: '#adb5c7',
+    scalePointsOnZoom: true,
+    linkDefaultArrows: false,
+    linkDefaultWidth: 2,
+    curvedLinks: true,
+    enableSimulation: false,
+    linkSamplingDistance: 50,
+    attribution: 'visualized with <a href="https://cosmograph.app/" style="color: var(--cosmosgl-attribution-color);" target="_blank">Cosmograph</a>',
+    onZoom: () => linkLabels.update(graph),
+    onDragEnd: () => linkLabels.update(graph),
+  }
+
+  const graph = new Graph(graphDiv, config)
+
+  graph.setPointPositions(data.pointPositions)
+  graph.setLinks(data.links)
+  graph.setLinkColors(data.linkColors)
+
+  graph.render()
+
+  const destroy = (): void => {
+    graph.destroy()
+  }
+
+  return { div, graph, destroy }
+}
diff --git a/src/stories/beginners/link-sampling/labels.ts b/src/stories/beginners/link-sampling/labels.ts
new file mode 100644
index 00000000..f4d9ee8c
--- /dev/null
+++ b/src/stories/beginners/link-sampling/labels.ts
@@ -0,0 +1,83 @@
+import { LabelRenderer, LabelOptions } from '@interacta/css-labels'
+import { Graph } from '@cosmos.gl/graph'
+
+const FONT_SIZE = 12
+const LINE_HEIGHT = 1.4
+const LABEL_PADDING = {
+  top: 6,
+  right: 9,
+  bottom: 6,
+  left: 9,
+}
+
+/** Normalize to (-90, 90] so text is never upside down. Returns rotation and whether it was flipped. */
+function normalizeRotation (deg: number): { rotation: number; flipped: boolean } {
+  let d = deg
+  while (d > 90) d -= 180
+  while (d <= -90) d += 180
+  const flipped = deg > 90 || deg <= -90
+  return { rotation: d, flipped }
+}
+
+export class LinkSamplingLabels {
+  private labelRenderer: LabelRenderer
+
+  public constructor (container: HTMLDivElement) {
+    this.labelRenderer = new LabelRenderer(container, { pointerEvents: 'none' })
+  }
+
+  public update (graph: Graph): void {
+    const { indices, positions, angles } = graph.getSampledLinks()
+    const linkColors = graph.getLinkColors()
+    const links = graph.graph.links
+
+    const labelOptions: LabelOptions[] = indices.map((linkIdx, i) => {
+      // Text
+      const source = Math.round(links?.[linkIdx * 2] ?? 0)
+      const target = Math.round(links?.[linkIdx * 2 + 1] ?? 0)
+      const text = links != null ? `${source} → ${target}` : String(linkIdx)
+
+      // Color
+      const base = linkIdx * 4
+      const hasColor = linkColors.length >= base + 4
+      const r = Math.round((linkColors[base] ?? 0) * 255)
+      const g = Math.round((linkColors[base + 1] ?? 0) * 255)
+      const b = Math.round((linkColors[base + 2] ?? 0) * 255)
+      const color = hasColor ? `rgba(${r}, ${g}, ${b}, 0.9)` : 'rgba(120, 120, 140, 0.9)'
+
+      // Position and rotation
+      const [screenX, screenY] = graph.spaceToScreenPosition([
+        positions[i * 2] ?? 0,
+        positions[i * 2 + 1] ?? 0,
+      ])
+      const angleRad = angles[i] ?? 0
+      const { rotation, flipped } = normalizeRotation((angleRad * 180) / Math.PI)
+
+      // Outer normal of the curve (perpendicular to chord, toward control point)
+      const outerX = Math.sin(angleRad)
+      const outerY = -Math.cos(angleRad)
+
+      // When flipped, the label extends inward from its anchor; push the anchor out by
+      // the full label height so the inner edge stays at the curve.
+      const labelHeight = LINE_HEIGHT * FONT_SIZE + LABEL_PADDING.top + LABEL_PADDING.bottom
+      const dist = flipped ? labelHeight : 0
+      const x = screenX + dist * outerX
+      const y = screenY + dist * outerY
+
+      return {
+        id: `link-${linkIdx}`,
+        text,
+        x,
+        y,
+        rotation,
+        fontSize: FONT_SIZE,
+        padding: LABEL_PADDING,
+        opacity: 0.9,
+        style: `background: none; color: ${color}; font-weight: 500`,
+      }
+    })
+
+    this.labelRenderer.setLabels(labelOptions)
+    this.labelRenderer.draw()
+  }
+}
diff --git a/src/stories/beginners/link-sampling/style.css b/src/stories/beginners/link-sampling/style.css
new file mode 100644
index 00000000..2a7f1bb7
--- /dev/null
+++ b/src/stories/beginners/link-sampling/style.css
@@ -0,0 +1,10 @@
+.link-sampling-demo {
+  position: relative;
+}
+
+.link-sampling-labels {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+}
+
diff --git a/src/variables.ts b/src/variables.ts
index 3912fa5a..cf7b8b7d 100644
--- a/src/variables.ts
+++ b/src/variables.ts
@@ -55,7 +55,8 @@ export const defaultConfigValues = {
   fitViewDelay: 250,
   fitViewPadding: 0.1,
   fitViewDuration: 250,
-  pointSamplingDistance: 150,
+  pointSamplingDistance: 100,
+  linkSamplingDistance: 100,
   attribution: '',
   rescalePositions: undefined,
   enableRightClickRepulsion: false,
