From 8e8eadc3557cbaee70642ac9ea6a1a28ce9cbe7f Mon Sep 17 00:00:00 2001 From: Speleobrad Date: Wed, 3 Sep 2025 17:01:11 +0100 Subject: [PATCH] Add depth profile preview section to survey visualization Enhance the SVG survey card preview with a dedicated depth profile section below the main plan view. The depth profile displays survey points at their actual depths with focused depth ranges for better detail visibility. Key improvements include: - Dynamic SVG cropping to survey content with consistent 3:4 aspect ratio - Professional color scheme with cream plan view and light blue depth profile backgrounds - Improved grid system with dashed lines and proper labels positioned within viewBox boundaries - Enhanced survey line styling in dark blue with increased visibility - Station markers using sequential numbers (0,1,2...) instead of depth values - North arrow with compass styling and proper rotation handling - Depth profile section showing actual survey depths with focused range scaling - Better handling of problematic shots with dashed line indicators This significantly improves survey data visualization by providing both plan and depth perspectives in a single preview thumbnail. --- lib/widgets/sectioncard.dart | 429 +++++++++++++++++++++++++++++++++-- 1 file changed, 410 insertions(+), 19 deletions(-) diff --git a/lib/widgets/sectioncard.dart b/lib/widgets/sectioncard.dart index 2109235..60b3087 100644 --- a/lib/widgets/sectioncard.dart +++ b/lib/widgets/sectioncard.dart @@ -30,7 +30,8 @@ class SectionCardState extends State { void initState() { super.initState(); map = MapSurvey.build(widget.section); - rawSvg = buildSVG(map.buildDisplayMap(displayWidth, displayHeight)); + final displayMap = map.buildDisplayMap(displayWidth, displayHeight); + rawSvg = buildSVG(map, displayMap); picture = SvgPicture.string( rawSvg, width: (Platform.isAndroid || Platform.isIOS) ? 50 : 200, @@ -97,38 +98,428 @@ String generateRandomString(int length) { ); } - String buildSVG(MapSurvey map) { + double _calculateOptimalGridSpacing(double maxExtent) { + // Target: no more than 10 grid divisions in the largest direction + const maxGridDivisions = 10; + + // Calculate minimum grid spacing needed + final minSpacing = maxExtent / maxGridDivisions; + + // Define preferred grid spacings (multiples of 5m) + const preferredSpacings = [5.0, 10.0, 15.0, 20.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0]; + + // Find the smallest preferred spacing that's >= minSpacing + for (final spacing in preferredSpacings) { + if (spacing >= minSpacing) { + return spacing; + } + } + + // If extent is very large, calculate a custom spacing + // Round up to nearest multiple of 5 + final customSpacing = ((minSpacing / 5.0).ceil() * 5.0); + return customSpacing; + } + + double _calculateSurveyRotationAngle(MapSurvey realMap) { + if (realMap.points.length < 2) return 0.0; + + // Get first and last points + final firstPoint = realMap.points.first; + final lastPoint = realMap.points.last; + + // Calculate the angle from first to last point + final deltaX = lastPoint.x - firstPoint.x; + final deltaY = lastPoint.y - firstPoint.y; + + // Calculate angle in radians (atan2 gives angle from positive X-axis) + // We want the line to go from left to right, so we rotate to align with X-axis + final angleRadians = atan2(deltaY, deltaX); + + // Return negative angle to rotate the survey to horizontal + return -angleRadians; + } + + MapSurvey _rotateSurvey(MapSurvey originalMap, double angleRadians) { + if (originalMap.points.isEmpty) return originalMap; + + final rotatedMap = MapSurvey(); + final centerX = originalMap.points.first.x; + final centerY = originalMap.points.first.y; + + for (int i = 0; i < originalMap.points.length; i++) { + final point = originalMap.points[i]; + + // Translate to origin + final translatedX = point.x - centerX; + final translatedY = point.y - centerY; + + // Rotate + final rotatedX = translatedX * cos(angleRadians) - translatedY * sin(angleRadians); + final rotatedY = translatedX * sin(angleRadians) + translatedY * cos(angleRadians); + + // Translate back + rotatedMap.points.add(Point3d( + rotatedX + centerX, + rotatedY + centerY, + point.z + )); + + // Copy problematic shot tracking + if (i < originalMap.isProblematicShot.length) { + rotatedMap.isProblematicShot.add(originalMap.isProblematicShot[i]); + } + } + + return rotatedMap; + } + + void _addDepthProfile(StringBuffer result, MapSurvey realMap, MapSurvey rotatedDisplayMap, + double cropMinX, double cropMaxX, double depthSectionStartY, double depthSectionHeight) { + if (realMap.points.isEmpty) return; + + // Calculate actual depth range from original real map (focused range) + double minDepth = realMap.points.first.z; + double maxDepth = realMap.points.first.z; + for (final point in realMap.points) { + if (point.z < minDepth) minDepth = point.z; + if (point.z > maxDepth) maxDepth = point.z; + } + + // Focus on actual range with minimal padding (round to nearest 5m) + final focusedMinDepth = (minDepth / 5.0).floor() * 5.0; + final focusedMaxDepth = (maxDepth / 5.0).ceil() * 5.0; + final depthRange = focusedMaxDepth - focusedMinDepth; + + // Create focused depth grid lines every 5m (only for the relevant range) + for (double depth = focusedMinDepth; depth <= focusedMaxDepth; depth += 5.0) { + final y = depthSectionStartY + ((depth - focusedMinDepth) / depthRange) * depthSectionHeight; + + result.writeln( + "" + ); + + // Add depth labels on the left side (same style as main section) + result.writeln( + "${depth.toInt()}m" + ); + } + + // Plot depth profile points using X coordinates from rotated display map + for (int i = 0; i < realMap.points.length - 1; i++) { + final realPoint = realMap.points[i]; + final displayX = rotatedDisplayMap.points[i].x; + + // Map depth to Y coordinate in depth section + final depthY = depthSectionStartY + ((realPoint.z - focusedMinDepth) / depthRange) * depthSectionHeight; + + // Draw point (same colors as main section) + result.write( + "" + "${realPoint.z.toStringAsFixed(1)}" + ); + + // Draw line to next point if not the last (same color as main section) + if (i < realMap.points.length - 2) { + final nextRealPoint = realMap.points[i + 1]; + final nextDisplayX = rotatedDisplayMap.points[i + 1].x; + final nextDepthY = depthSectionStartY + ((nextRealPoint.z - focusedMinDepth) / depthRange) * depthSectionHeight; + + // Check if this shot is problematic (calculated length) + final isProblematic = i + 1 < realMap.isProblematicShot.length && realMap.isProblematicShot[i + 1]; + final strokeDashArray = isProblematic ? "stroke-dasharray:5,5;" : ""; + + result.write( + "" + ); + } + } + } + + void _addNorthArrow(StringBuffer result, double cropMinX, double cropMaxX, double cropMinY, double cropMaxY, [double surveyRotationAngle = 0.0]) { + // Position in lower right corner of the cropped viewBox with some padding + final arrowCenterX = cropMaxX - 30; + final arrowCenterY = cropMaxY - 30; + + // North direction in original coordinate system is +Y (upward in SVG) + // Account for survey rotation: if survey is rotated, north arrow rotates opposite direction + final northAngleDegrees = -surveyRotationAngle * 180 / pi; + + // Create compass-style north arrow with pointed diamond design + result.writeln( + "" + "" + "" + // North-pointing diamond (professional red) + "" + // South-pointing diamond (white/light) + "" + // N label on the north-pointing side (rotates with arrow) + "N" + "" + "" + ); + } + + void _addGridlines(StringBuffer result, MapSurvey realMap, MapSurvey displayMap, double cropMinX, double cropMaxX, double cropMinY, double cropMaxY) { + if (realMap.points.isEmpty || displayMap.points.isEmpty) return; + + // Get the first point as origin (0,0) for grid system in real coordinates + final realOriginX = realMap.points[0].x; + final realOriginY = realMap.points[0].y; + + // Calculate the range of real coordinates to determine grid bounds + double realMinX = realMap.points[0].x; + double realMaxX = realMap.points[0].x; + double realMinY = realMap.points[0].y; + double realMaxY = realMap.points[0].y; + + for (final point in realMap.points) { + if (point.x < realMinX) realMinX = point.x; + if (point.x > realMaxX) realMaxX = point.x; + if (point.y < realMinY) realMinY = point.y; + if (point.y > realMaxY) realMaxY = point.y; + } + + // Get display coordinate bounds for clipping + double displayMinX = displayMap.points[0].x; + double displayMaxX = displayMap.points[0].x; + double displayMinY = displayMap.points[0].y; + double displayMaxY = displayMap.points[0].y; + + for (final point in displayMap.points) { + if (point.x < displayMinX) displayMinX = point.x; + if (point.x > displayMaxX) displayMaxX = point.x; + if (point.y < displayMinY) displayMinY = point.y; + if (point.y > displayMaxY) displayMaxY = point.y; + } + + // Calculate transformation parameters + final realXSize = realMaxX - realMinX; + final realYSize = realMaxY - realMinY; + final realMaxSize = max(realXSize, realYSize); + + // Convert coordinates relative to origin for grid calculation + final gridMinX = realMinX - realOriginX; + final gridMaxX = realMaxX - realOriginX; + final gridMinY = realMinY - realOriginY; + final gridMaxY = realMaxY - realOriginY; + + // Calculate dynamic grid spacing to ensure max 10x10 grid + final maxExtent = max(gridMaxX - gridMinX, gridMaxY - gridMinY); + final gridSpacing = _calculateOptimalGridSpacing(maxExtent); + + + // Transform a real coordinate to display coordinate + Point realToDisplay(double realX, double realY) { + final dispX = (realX - realMinX - (realMaxX - realMinX) / 2.0) * displayWidth / realMaxSize + displayWidth / 2; + final dispY = (realY - realMinY - (realMaxY - realMinY) / 2.0) * displayHeight / realMaxSize + displayHeight / 2; + return Point(dispX, dispY); + } + + // Calculate extended grid bounds to fill entire SVG viewport + // Convert SVG viewport bounds back to real coordinates for extended grid + final svgMinX = -margin; + final svgMaxX = displayWidth + margin; + final svgMinY = -margin; + final svgMaxY = displayHeight + margin; + + // Convert SVG bounds to real coordinates (inverse transformation) + Point displayToReal(double dispX, double dispY) { + final realX = ((dispX - displayWidth / 2) * realMaxSize / displayWidth) + (realMaxX + realMinX) / 2; + final realY = ((dispY - displayHeight / 2) * realMaxSize / displayHeight) + (realMaxY + realMinY) / 2; + return Point(realX, realY); + } + + final svgTopLeft = displayToReal(svgMinX, svgMinY); + final svgBottomRight = displayToReal(svgMaxX, svgMaxY); + + // Calculate extended grid bounds relative to origin + final extendedGridMinX = svgTopLeft.x - realOriginX; + final extendedGridMaxX = svgBottomRight.x - realOriginX; + final extendedGridMinY = svgTopLeft.y - realOriginY; + final extendedGridMaxY = svgBottomRight.y - realOriginY; + + // Calculate extended grid line positions + final extendedStartGridX = (extendedGridMinX / gridSpacing).floor() * gridSpacing; + final extendedEndGridX = (extendedGridMaxX / gridSpacing).ceil() * gridSpacing; + final extendedStartGridY = (extendedGridMinY / gridSpacing).floor() * gridSpacing; + final extendedEndGridY = (extendedGridMaxY / gridSpacing).ceil() * gridSpacing; + + // Draw vertical grid lines (clipped to section height) + for (double gridX = extendedStartGridX; gridX <= extendedEndGridX; gridX += gridSpacing) { + final realX = realOriginX + gridX; + final topPoint = realToDisplay(realX, svgTopLeft.y); + final bottomPoint = realToDisplay(realX, svgBottomRight.y); + + // Clip the line to the section bounds + final clippedTopY = max(topPoint.y, cropMinY); + final clippedBottomY = min(bottomPoint.y, cropMaxY); + + result.writeln( + "" + ); + + // Add label at bottom edge of section + final absoluteDistance = (realX - realOriginX).abs(); + result.writeln( + "${absoluteDistance.toInt()}m" + ); + } + + // Draw horizontal grid lines (full SVG width, clipped to section) + for (double gridY = extendedStartGridY; gridY <= extendedEndGridY; gridY += gridSpacing) { + final realY = realOriginY + gridY; + final leftPoint = realToDisplay(svgTopLeft.x, realY); + final rightPoint = realToDisplay(svgBottomRight.x, realY); + + // Only draw line if it's within the section bounds + if (leftPoint.y >= cropMinY && leftPoint.y <= cropMaxY) { + result.writeln( + "" + ); + + // Add label at right edge of cropped SVG (with padding for visibility) + final absoluteDistance = (realY - realOriginY).abs(); + result.writeln( + "${absoluteDistance.toInt()}m" + ); + } + } + } + + String buildSVG(MapSurvey realMap, MapSurvey displayMap) { + // Calculate rotation angle to align survey left-to-right + final rotationAngle = _calculateSurveyRotationAngle(realMap); + + // Create rotated version of survey data + final rotatedRealMap = _rotateSurvey(realMap, rotationAngle); + final rotatedDisplayMap = rotatedRealMap.buildDisplayMap(displayWidth, displayHeight); + + // Calculate actual bounds of the rotated survey + double minX = rotatedDisplayMap.points.first.x; + double maxX = rotatedDisplayMap.points.first.x; + double minY = rotatedDisplayMap.points.first.y; + double maxY = rotatedDisplayMap.points.first.y; + + for (final point in rotatedDisplayMap.points) { + if (point.x < minX) minX = point.x; + if (point.x > maxX) maxX = point.x; + if (point.y < minY) minY = point.y; + if (point.y > maxY) maxY = point.y; + } + + // Expand bounds to include grid labels and north arrow + // Bottom labels extend 15px below survey area + // Right labels extend 25px to the right of survey area + // North arrow is 40px from bottom-right corner + const labelPadding = 30.0; // Extra space for labels + final cropMinX = minX - labelPadding; + final cropMaxX = maxX + labelPadding; + final cropMinY = minY - labelPadding; + final cropMaxY = maxY + labelPadding; + + // Calculate cropped dimensions + final cropWidth = cropMaxX - cropMinX; + final cropHeight = cropMaxY - cropMinY; + + // Force 3:4 aspect ratio (height:width) + final targetAspectRatio = 3.0 / 4.0; // height / width = 0.75 + final currentAspectRatio = cropHeight / cropWidth; + + double finalCropWidth, finalCropHeight; + double finalCropMinX, finalCropMinY; + + if (currentAspectRatio > targetAspectRatio) { + // Content is too tall, expand width + finalCropHeight = cropHeight; + finalCropWidth = cropHeight / targetAspectRatio; + finalCropMinY = cropMinY; + finalCropMinX = cropMinX - (finalCropWidth - cropWidth) / 2; + } else { + // Content is too wide, expand height + finalCropWidth = cropWidth; + finalCropHeight = cropWidth * targetAspectRatio; + finalCropMinX = cropMinX; + finalCropMinY = cropMinY - (finalCropHeight - cropHeight) / 2; + } + + // Extend height by 25% for depth profile section plus padding + final sectionPadding = 10.0; // Padding between sections + final extendedCropHeight = finalCropHeight * 1.25 + sectionPadding; + + // Calculate scale factor to ensure max dimension doesn't exceed 800px + final maxDimension = max(finalCropWidth, extendedCropHeight); + final scaleFactor = maxDimension > 800 ? 800 / maxDimension : 1.0; + final finalWidth = (finalCropWidth * scaleFactor).round(); + final finalHeight = (extendedCropHeight * scaleFactor).round(); + + // Define sections: main map keeps original height, depth profile extends below with padding + final mainSectionHeight = finalCropHeight; + final depthSectionHeight = finalCropHeight * 0.25; // 25% of main section height + final depthSectionStartY = finalCropMinY + mainSectionHeight + sectionPadding; + StringBuffer result = StringBuffer(""); result.writeln( - ""); + ""); + // Main survey area background (plan view) - cream for better contrast + result.writeln( + ""); + + // Depth profile area background (light blue for depth association) - opaque result.writeln( - ""); + ""); - // Render survey lines - use individual line segments to handle dashing - for (int i = 0; i < map.points.length-2; i++) { - final isProblematic = i + 1 < map.isProblematicShot.length && map.isProblematicShot[i + 1]; + // Add gridlines for main survey section only (screen-aligned, not rotated) + _addGridlines(result, rotatedRealMap, rotatedDisplayMap, finalCropMinX, finalCropMinX + finalCropWidth, finalCropMinY, finalCropMinY + finalCropHeight); + + // Render survey lines - use rotated map data + for (int i = 0; i < rotatedDisplayMap.points.length-2; i++) { + final isProblematic = i + 1 < rotatedRealMap.isProblematicShot.length && rotatedRealMap.isProblematicShot[i + 1]; final strokeDashArray = isProblematic ? "stroke-dasharray:5,5;" : ""; result.write( - "" + "" ); } - for (int i = 0; i < map.points.length-1; i++) { - int adjX = map.points[i].x < displayWidth * 3/4 ? 5 : -40; // move text starting point to the left if we're in the right band of SVG - int adjY = map.points[i].y < displayHeight * 1/5 ? 13 : -3; // lower the textpath if we're in the upper part of the SVG + // Render survey points with rotated data + for (int i = 0; i < rotatedDisplayMap.points.length-1; i++) { + int adjX = rotatedDisplayMap.points[i].x < finalCropMinX + finalCropWidth * 3/4 ? 5 : -40; + int adjY = rotatedDisplayMap.points[i].y < finalCropMinY + finalCropHeight * 1/5 ? 13 : -3; result.write( - "" - "${map.points[i].z.toStringAsFixed(1)}"); + "" + "$i"); } + // Add north arrow accounting for survey rotation (position in main section only) + _addNorthArrow(result, finalCropMinX, finalCropMinX + finalCropWidth, finalCropMinY, finalCropMinY + finalCropHeight, rotationAngle); + + // Add depth profile section + _addDepthProfile(result, realMap, rotatedDisplayMap, finalCropMinX, finalCropMinX + finalCropWidth, depthSectionStartY, depthSectionHeight); + result.write(""); return result.toString();