//
//  ColorPlotView.m
//  SnoopX
//

#import "ColorPlotView.h"

#import <math.h>

// Plotting margin around the chart area (points)
static const CGFloat kPlotMargin = 36.0;

const CGFloat ColorPlotWaveformTopMargin = 14.0;
const CGFloat ColorPlotWaveformBottomMargin = 18.0;

// Vectorscope hue-ring thickness as fraction of plot radius
static const CGFloat kHueRingFraction = 0.06;

// Vectorscope primary/secondary marker geometry.
// The primary markers (R/Y/G/C/B/M) sit a fixed distance inside the hue ring;
// fully-saturated source pixels (e.g. 1,0,0) land at that radius.
static const CGFloat kPrimaryMarkerInset = 14.0;  // points inside the ring's inner edge
static const CGFloat kPrimaryMarkerSize  = 12.0;  // marker square edge, in points

// Rotation offset applied to all hue angles, in degrees, so red (hue=0°) sits
// at the top of the wheel.
static const CGFloat kHueAngleOffset = 90.0;

// HDR waveform Y-axis: linear 0..4, matching the h-plot's HDR canvas scale.
static const float kHDRWaveformMax = 4.0f;

// Waveform alpha-per-hit. Each source pixel contributes this much opacity at
// its luminance row, painted in the pixel's actual RGB color. Density translates
// to opacity (sparse → translucent on black background, dense → fully opaque),
// while hue is preserved exactly because we average source colors rather than
// gamma-correcting each channel independently.
static const float kWaveformGain = 0.04f;

// Gamma applied only to the alpha channel — boosts low-density trails so rare
// colors stay visible, while not touching RGB ratios (hue is preserved).
static const float kWaveformAlphaGamma = 2.5f;

// Same idea for the vectorscope: source-color averaging at each polar bin,
// alpha-by-density with its own gamma curve.
static const float kVectorscopeGain       = 0.04f;
static const float kVectorscopeAlphaGamma = 2.5f;

// Subsample target: cap how many source pixels we plot per frame for the vectorscope.
// (Waveform iterates per-column so its cost scales with output width.)
static const size_t kVectorscopeMaxSamples = 200000;

// --- Pixel readers --------------------------------------------------------

// Returns linear-ish RGB in the range used by the source.
// SDR: 0..1 (from 8-bit BGRA). HDR: float values, possibly > 1.0.
static inline void readPixel(const SnoopxFrameSnapshot *snap, size_t x, size_t y,
                             float *outR, float *outG, float *outB)
{
	if (snap->isHDR) {
		const float *row = (const float *)((const uint8_t *)snap->pixels + y * snap->bytesPerRow);
		const float *px = row + x * 4;
		*outR = px[0];
		*outG = px[1];
		*outB = px[2];
	} else {
		// captureData is BGRA in memory order
		const uint8_t *row = (const uint8_t *)snap->pixels + y * snap->bytesPerRow;
		const uint8_t *px = row + x * 4;
		*outR = px[2] / 255.0f;
		*outG = px[1] / 255.0f;
		*outB = px[0] / 255.0f;
	}
}

// Compute HSV from RGB. r/g/b can exceed 1.0 in HDR.
void SnoopxRGBToHSV(float r, float g, float b, float *outH, float *outS, float *outV)
{
	float maxc = fmaxf(r, fmaxf(g, b));
	float minc = fminf(r, fminf(g, b));
	float chroma = maxc - minc;
	float h = 0.0f;
	if (chroma > 0.0f) {
		if (maxc == r) {
			h = fmodf((g - b) / chroma, 6.0f);
		} else if (maxc == g) {
			h = (b - r) / chroma + 2.0f;
		} else {
			h = (r - g) / chroma + 4.0f;
		}
		h *= 60.0f;
		if (h < 0.0f) h += 360.0f;
	}
	*outH = h;
	*outS = (maxc <= 0.0f) ? 0.0f : (chroma / maxc);
	*outV = maxc;
}

// --- View -----------------------------------------------------------------

@implementation ColorPlotView

- (instancetype) initWithFrame:(NSRect)frameRect
{
	self = [super initWithFrame:frameRect];
	if (self) {
		plotMode = ColorPlotModeVectorscope;
		bzero(&snapshot, sizeof(snapshot));
		hasCursorColor = NO;
		cursorR = cursorG = cursorB = 0.0f;
	}
	return self;
}

- (void) setCursorR:(float)r G:(float)g B:(float)b
{
	cursorR = r;
	cursorG = g;
	cursorB = b;
	hasCursorColor = YES;
	if (plotMode == ColorPlotModeVectorscope) {
		[self setNeedsDisplay:YES];
	}
}

- (void) clearCursorColor
{
	hasCursorColor = NO;
	if (plotMode == ColorPlotModeVectorscope) {
		[self setNeedsDisplay:YES];
	}
}

- (void) dealloc
{
	if (snapshot.pixels) {
		free(snapshot.pixels);
		snapshot.pixels = NULL;
	}
	[super dealloc];
}

- (BOOL) isFlipped { return NO; }

- (void) setPlotMode:(ColorPlotMode)mode
{
	if (plotMode != mode) {
		plotMode = mode;
		[self setNeedsDisplay:YES];
	}
}

- (void) setSnapshot:(SnoopxFrameSnapshot)newSnap
{
	if (snapshot.pixels) {
		free(snapshot.pixels);
	}
	snapshot = newSnap;
	[self setNeedsDisplay:YES];
}

- (void) drawRect:(NSRect)dirtyRect
{
	// Black background
	[[NSColor blackColor] setFill];
	NSRectFill([self bounds]);

	if (snapshot.pixels == NULL || snapshot.width == 0 || snapshot.height == 0) {
		return;
	}

	if (plotMode == ColorPlotModeVectorscope) {
		[self drawVectorscope];
	} else if (plotMode == ColorPlotModeHistogram) {
		[self drawHistogram];
	} else {
		[self drawWaveform];
	}
}

// --- Vectorscope ------------------------------------------------------

- (void) drawVectorscope
{
	NSRect b = [self bounds];
	CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];

	CGFloat cx = NSMidX(b);
	CGFloat cy = NSMidY(b);
	CGFloat radius = fmin(b.size.width, b.size.height) / 2.0 - kPlotMargin;
	if (radius < 8.0) return;

	CGFloat ringInner = radius;
	CGFloat ringOuter = radius * (1.0 + kHueRingFraction);

	// Primary/secondary marker radius — fully-saturated source colors land here,
	// not on the hue ring itself.
	CGFloat primaryRadius = ringInner - kPrimaryMarkerInset;
	if (primaryRadius < 8.0) primaryRadius = ringInner * 0.85;

	// Hue ring: smooth color wheel along the perimeter.
	// Stroke many short colored arc segments. The kHueAngleOffset rotates the wheel
	// so yellow ends up at the top and blue at the bottom.
	CGFloat ringWidth = ringOuter - ringInner;
	CGFloat ringMid = (ringOuter + ringInner) * 0.5;
	const int hueSteps = 360;
	CGFloat offsetRad = kHueAngleOffset * (CGFloat)(M_PI / 180.0);
	CGContextSaveGState(ctx);
	CGContextSetLineWidth(ctx, ringWidth);
	CGContextSetLineCap(ctx, kCGLineCapButt);
	for (int i = 0; i < hueSteps; ++i) {
		CGFloat hue = (CGFloat)i / hueSteps;
		NSColor *c = [NSColor colorWithCalibratedHue:hue saturation:1.0 brightness:1.0 alpha:1.0];
		CGFloat r, g, bl, a;
		[c getRed:&r green:&g blue:&bl alpha:&a];
		CGContextSetRGBStrokeColor(ctx, r, g, bl, 1.0);
		CGFloat a0 = (CGFloat)i       * (CGFloat)(2.0 * M_PI) / hueSteps + offsetRad;
		CGFloat a1 = (CGFloat)(i + 1) * (CGFloat)(2.0 * M_PI) / hueSteps + offsetRad;
		CGContextBeginPath(ctx);
		CGContextAddArc(ctx, cx, cy, ringMid, a0, a1, 0);
		CGContextStrokePath(ctx);
	}
	CGContextRestoreGState(ctx);

	// Primary/secondary markers (R, Y, G, C, B, M) — small gray "graticule" boxes
	// at primaryRadius. Each marker is rotated so its edges are locally radial /
	// tangent to the hue circle. Fully-saturated source colors land at the marker
	// centers.
	{
		const CGFloat primaryHues[6] = {0.0, 60.0, 120.0, 180.0, 240.0, 300.0};
		CGFloat half = kPrimaryMarkerSize * 0.5;
		for (int i = 0; i < 6; ++i) {
			CGFloat theta = (primaryHues[i] + kHueAngleOffset) * (CGFloat)(M_PI / 180.0);
			CGFloat mx = cx + primaryRadius * cos(theta);
			CGFloat my = cy + primaryRadius * sin(theta);

			CGContextSaveGState(ctx);
			CGContextSetRGBStrokeColor(ctx, 0.55, 0.55, 0.55, 1.0);
			CGContextSetLineWidth(ctx, 1.0);
			CGContextTranslateCTM(ctx, mx, my);
			CGContextRotateCTM(ctx, theta);
			// Draw the box centered at the (now-rotated) origin.
			CGContextStrokeRect(ctx, CGRectMake(-half, -half,
			                                    kPrimaryMarkerSize,
			                                    kPrimaryMarkerSize));
			// Cross-hairs to make a 2×2 mini-grid in the rotated frame.
			CGContextBeginPath(ctx);
			CGContextMoveToPoint(ctx, -half, 0);
			CGContextAddLineToPoint(ctx, half, 0);
			CGContextMoveToPoint(ctx, 0, -half);
			CGContextAddLineToPoint(ctx, 0, half);
			CGContextStrokePath(ctx);
			CGContextRestoreGState(ctx);
		}
	}

	size_t pxW = (size_t)ceil(b.size.width);
	size_t pxH = (size_t)ceil(b.size.height);
	if (pxW == 0 || pxH == 0) return;

	// Float accumulator: 4 floats = (r_sum, g_sum, b_sum, count) per pixel.
	// Color = average of source RGBs landing here (preserves hue exactly);
	// alpha = density-driven (gamma-curved) so popular HSV positions are opaque
	// and rare ones are translucent.
	size_t numPixels = pxW * pxH;
	float *acc = (float *)calloc(numPixels * 4, sizeof(float));
	if (!acc) return;

	// Subsampling stride
	size_t totalPixels = snapshot.width * snapshot.height;
	size_t stride = 1;
	if (totalPixels > kVectorscopeMaxSamples) {
		stride = (size_t)ceil((double)totalPixels / (double)kVectorscopeMaxSamples);
	}

	size_t idx = 0;
	for (size_t y = 0; y < snapshot.height; ++y) {
		for (size_t x = 0; x < snapshot.width; ++x, ++idx) {
			if (idx % stride != 0) continue;

			float r, g, blu;
			readPixel(&snapshot, x, y, &r, &g, &blu);

			float hue, sat, val;
			SnoopxRGBToHSV(r, g, blu, &hue, &sat, &val);

			// Skip near-black pixels (no chroma signal, would just clutter the center)
			if (val < 0.01f) continue;

			if (sat > 1.0f) sat = 1.0f;
			if (sat < 0.0f) sat = 0.0f;

			CGFloat theta = (hue + kHueAngleOffset) * (CGFloat)(M_PI / 180.0);
			CGFloat px_f = cx + sat * primaryRadius * cos(theta);
			CGFloat py_f = cy + sat * primaryRadius * sin(theta);

			if (px_f < 0 || px_f >= (CGFloat)pxW || py_f < 0 || py_f >= (CGFloat)pxH) continue;
			size_t px_i = (size_t)px_f;
			size_t py_i = pxH - 1 - (size_t)py_f;

			float cr = fminf(fmaxf(r,   0.0f), 1.0f);
			float cg = fminf(fmaxf(g,   0.0f), 1.0f);
			float cb = fminf(fmaxf(blu, 0.0f), 1.0f);

			float *a = acc + (py_i * pxW + px_i) * 4;
			a[0] += cr;
			a[1] += cg;
			a[2] += cb;
			a[3] += 1.0f;
		}
	}

	// Convert accumulator → RGBA premultiplied bitmap.
	size_t bytesPerRow = pxW * 4;
	uint8_t *bmp = calloc(pxH, bytesPerRow);
	if (!bmp) {
		free(acc);
		return;
	}
	uint8_t alphaLUT[1024];
	const float invAlphaGamma = 1.0f / kVectorscopeAlphaGamma;
	for (int i = 0; i < 1024; ++i) {
		float v = (float)i / 1023.0f;
		alphaLUT[i] = (uint8_t)(powf(v, invAlphaGamma) * 255.0f + 0.5f);
	}
	for (size_t y = 0; y < pxH; ++y) {
		const float *aRow = acc + y * pxW * 4;
		uint8_t *bRow = bmp + y * bytesPerRow;
		for (size_t x = 0; x < pxW; ++x) {
			float r_sum = aRow[x*4 + 0];
			float g_sum = aRow[x*4 + 1];
			float b_sum = aRow[x*4 + 2];
			float cnt   = aRow[x*4 + 3];
			if (cnt < 1.0f) {
				bRow[x*4 + 0] = bRow[x*4 + 1] = bRow[x*4 + 2] = bRow[x*4 + 3] = 0;
				continue;
			}
			float invCnt = 1.0f / cnt;
			float avg_r = r_sum * invCnt;
			float avg_g = g_sum * invCnt;
			float avg_b = b_sum * invCnt;
			float alpha_raw = cnt * kVectorscopeGain;
			if (alpha_raw > 1.0f) alpha_raw = 1.0f;
			int ai = (int)(alpha_raw * 1023.0f);
			if (ai < 0) ai = 0; else if (ai > 1023) ai = 1023;
			float alpha = (float)alphaLUT[ai] / 255.0f;
			bRow[x*4 + 0] = (uint8_t)(avg_r * alpha * 255.0f + 0.5f);
			bRow[x*4 + 1] = (uint8_t)(avg_g * alpha * 255.0f + 0.5f);
			bRow[x*4 + 2] = (uint8_t)(avg_b * alpha * 255.0f + 0.5f);
			bRow[x*4 + 3] = alphaLUT[ai];
		}
	}
	free(acc);

	CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
	CGContextRef bmpCtx = CGBitmapContextCreate(bmp, pxW, pxH, 8, bytesPerRow,
		cs, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
	CGImageRef img = bmpCtx ? CGBitmapContextCreateImage(bmpCtx) : NULL;
	if (img) {
		CGContextDrawImage(ctx, b, img);
		CGImageRelease(img);
	}
	if (bmpCtx) CGContextRelease(bmpCtx);
	CGColorSpaceRelease(cs);
	free(bmp);

	// Center crosshair: six darker-gray spokes from each primary marker inward
	// to the center. Drawn on top of the pixel-data blit so the neutral point
	// stays visible even when bright chroma blotches cluster near the center.
	{
		const CGFloat primaryHues[6] = {0.0, 60.0, 120.0, 180.0, 240.0, 300.0};
		CGFloat half     = kPrimaryMarkerSize * 0.5;
		CGFloat standoff = 5.0;
		CGFloat lineStartRadius = primaryRadius - half - standoff;
		if (lineStartRadius < 4.0) lineStartRadius = primaryRadius * 0.7;

		CGContextSaveGState(ctx);
		CGContextSetRGBStrokeColor(ctx, 0.30, 0.30, 0.30, 1.0);
		CGContextSetLineWidth(ctx, 0.75);
		CGContextBeginPath(ctx);
		for (int i = 0; i < 6; ++i) {
			CGFloat theta = (primaryHues[i] + kHueAngleOffset) * (CGFloat)(M_PI / 180.0);
			CGFloat sx = cx + lineStartRadius * cos(theta);
			CGFloat sy = cy + lineStartRadius * sin(theta);
			CGContextMoveToPoint(ctx, sx, sy);
			CGContextAddLineToPoint(ctx, cx, cy);
		}
		CGContextStrokePath(ctx);
		CGContextRestoreGState(ctx);
	}

	// Cursor marker: open red square at the HSV position of the snoop window's
	// center pixel, mirroring the red square in the snoop window itself.
	if (hasCursorColor) {
		float h, s, v;
		SnoopxRGBToHSV(cursorR, cursorG, cursorB, &h, &s, &v);
		if (s > 1.0f) s = 1.0f;
		if (s < 0.0f) s = 0.0f;
		CGFloat theta = (h + kHueAngleOffset) * (CGFloat)(M_PI / 180.0);
		CGFloat px = cx + s * primaryRadius * cos(theta);
		CGFloat py = cy + s * primaryRadius * sin(theta);

		const CGFloat sqSize = 4.0;
		const CGFloat halfSq = sqSize * 0.5;
		CGContextSaveGState(ctx);
		CGContextSetRGBStrokeColor(ctx, 1.0, 0.0, 0.0, 1.0);
		CGContextSetLineWidth(ctx, 1.5);
		CGContextStrokeRect(ctx, CGRectMake(px - halfSq, py - halfSq, sqSize, sqSize));
		CGContextRestoreGState(ctx);
	}
}

// --- Waveform ---------------------------------------------------------

- (void) drawWaveform
{
	NSRect b = [self bounds];
	CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];

	// Layout: leave room on left for axis labels (right side gets just a small
	// visual margin — only one ordinate axis is drawn), plus top/bottom margin.
	CGFloat leftLabelW = 38.0;
	CGFloat rightLabelW = 6.0;
	CGFloat topMargin = ColorPlotWaveformTopMargin;
	CGFloat bottomMargin = ColorPlotWaveformBottomMargin;

	NSRect chart = NSMakeRect(leftLabelW,
	                          bottomMargin,
	                          b.size.width  - leftLabelW - rightLabelW,
	                          b.size.height - topMargin  - bottomMargin);
	if (chart.size.width < 4 || chart.size.height < 4) return;

	BOOL isHDR = snapshot.isHDR;

	size_t pxW = (size_t)floor(chart.size.width);
	size_t pxH = (size_t)floor(chart.size.height);
	if (pxW == 0 || pxH == 0) return;

	// Per-output-pixel accumulator: 4 floats = (r_sum, g_sum, b_sum, count).
	// Color trails are the AVERAGE of source colors that landed at that row
	// (preserves hue), and density translates to alpha (preserves color too,
	// just darkens proportionally on the black background via premultiplied alpha).
	size_t numPixels = pxW * pxH;
	float *acc = (float *)calloc(numPixels * 4, sizeof(float));
	if (!acc) return;

	size_t srcW = snapshot.width;
	size_t srcH = snapshot.height;

	for (size_t outX = 0; outX < pxW; ++outX) {
		size_t srcX0 = (srcW * outX) / pxW;
		size_t srcX1 = (srcW * (outX + 1)) / pxW;
		if (srcX1 == srcX0) srcX1 = srcX0 + 1;
		if (srcX1 > srcW) srcX1 = srcW;

		for (size_t sx = srcX0; sx < srcX1; ++sx) {
			for (size_t y = 0; y < srcH; ++y) {
				float r, g, bl;
				readPixel(&snapshot, sx, y, &r, &g, &bl);

				float lum = 0.299f * r + 0.587f * g + 0.114f * bl;
				float yL = isHDR ? (lum / kHDRWaveformMax) : lum;
				yL = fminf(fmaxf(yL, 0.0f), 1.0f);
				size_t plotY = (size_t)((1.0f - yL) * (pxH - 1));

				float cr = fminf(fmaxf(r,  0.0f), 1.0f);
				float cg = fminf(fmaxf(g,  0.0f), 1.0f);
				float cb = fminf(fmaxf(bl, 0.0f), 1.0f);

				float *a = acc + (plotY * pxW + outX) * 4;
				a[0] += cr;
				a[1] += cg;
				a[2] += cb;
				a[3] += 1.0f;
			}
		}
	}

	// Convert float accumulator → 8-bit RGBA premultiplied bitmap.
	size_t bytesPerRow = pxW * 4;
	uint8_t *bmp = calloc(pxH, bytesPerRow);
	if (!bmp) {
		free(acc);
		return;
	}
	// Gamma LUT for alpha only (1024 entries keyed on raw alpha in [0,1]).
	uint8_t alphaLUT[1024];
	const float invAlphaGamma = 1.0f / kWaveformAlphaGamma;
	for (int i = 0; i < 1024; ++i) {
		float v = (float)i / 1023.0f;
		alphaLUT[i] = (uint8_t)(powf(v, invAlphaGamma) * 255.0f + 0.5f);
	}
	for (size_t y = 0; y < pxH; ++y) {
		const float *aRow = acc + y * pxW * 4;
		uint8_t *bRow = bmp + y * bytesPerRow;
		for (size_t x = 0; x < pxW; ++x) {
			float r_sum = aRow[x*4 + 0];
			float g_sum = aRow[x*4 + 1];
			float b_sum = aRow[x*4 + 2];
			float cnt   = aRow[x*4 + 3];
			if (cnt < 1.0f) {
				bRow[x*4 + 0] = bRow[x*4 + 1] = bRow[x*4 + 2] = bRow[x*4 + 3] = 0;
				continue;
			}
			// Hue-preserving color = average of source RGBs.
			float invCnt = 1.0f / cnt;
			float avg_r = r_sum * invCnt;
			float avg_g = g_sum * invCnt;
			float avg_b = b_sum * invCnt;
			// Density → raw alpha → gamma-curved alpha (LUT).
			float alpha_raw = cnt * kWaveformGain;
			if (alpha_raw > 1.0f) alpha_raw = 1.0f;
			int ai = (int)(alpha_raw * 1023.0f);
			if (ai < 0) ai = 0; else if (ai > 1023) ai = 1023;
			float alpha = (float)alphaLUT[ai] / 255.0f;
			bRow[x*4 + 0] = (uint8_t)(avg_r * alpha * 255.0f + 0.5f);
			bRow[x*4 + 1] = (uint8_t)(avg_g * alpha * 255.0f + 0.5f);
			bRow[x*4 + 2] = (uint8_t)(avg_b * alpha * 255.0f + 0.5f);
			bRow[x*4 + 3] = alphaLUT[ai];
		}
	}
	free(acc);

	// Blit bitmap into chart area
	CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
	CGContextRef bmpCtx = CGBitmapContextCreate(bmp, pxW, pxH, 8, bytesPerRow,
		cs, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
	CGImageRef img = bmpCtx ? CGBitmapContextCreateImage(bmpCtx) : NULL;
	if (img) {
		CGContextDrawImage(ctx, chart, img);
		CGImageRelease(img);
	}
	if (bmpCtx) CGContextRelease(bmpCtx);
	CGColorSpaceRelease(cs);
	free(bmp);

	// Axis gridlines and labels
	NSColor *gridColor = [NSColor colorWithCalibratedWhite:1.0 alpha:0.18];
	NSColor *labelColor = [NSColor colorWithCalibratedWhite:0.85 alpha:1.0];
	NSFont *labelFont = [NSFont systemFontOfSize:10.0];
	NSDictionary *labelAttrs = @{
		NSFontAttributeName: labelFont,
		NSForegroundColorAttributeName: labelColor
	};

	[gridColor setStroke];
	NSBezierPath *grid = [NSBezierPath bezierPath];
	[grid setLineWidth:0.5];

	if (isHDR) {
		// Linear 0..4 scale, integer ticks. Same range as the h-plot's HDR canvas.
		for (int i = 0; i <= (int)kHDRWaveformMax; ++i) {
			float frac = (float)i / kHDRWaveformMax;
			CGFloat y = chart.origin.y + frac * chart.size.height;
			[grid moveToPoint:NSMakePoint(chart.origin.x, y)];
			[grid lineToPoint:NSMakePoint(chart.origin.x + chart.size.width, y)];
			NSString *lab = [NSString stringWithFormat:@"%.1f", (double)i];
			NSSize ls = [lab sizeWithAttributes:labelAttrs];
			[lab drawAtPoint:NSMakePoint(chart.origin.x - ls.width - 4, y - ls.height/2)
			   withAttributes:labelAttrs];
		}
	} else {
		// SDR: gridlines every 25 IRE, labels on the left (IRE 0..100).
		for (int i = 0; i <= 4; ++i) {
			CGFloat frac = (CGFloat)i / 4.0;
			CGFloat y = chart.origin.y + frac * chart.size.height;
			[grid moveToPoint:NSMakePoint(chart.origin.x, y)];
			[grid lineToPoint:NSMakePoint(chart.origin.x + chart.size.width, y)];
			NSString *labL = [NSString stringWithFormat:@"%d", i * 25];
			NSSize ls = [labL sizeWithAttributes:labelAttrs];
			[labL drawAtPoint:NSMakePoint(chart.origin.x - ls.width - 4, y - ls.height/2)
			   withAttributes:labelAttrs];
		}
	}
	[grid stroke];

	// Chart border
	[[NSColor colorWithCalibratedWhite:1.0 alpha:0.5] setStroke];
	NSBezierPath *border = [NSBezierPath bezierPathWithRect:chart];
	[border setLineWidth:0.5];
	[border stroke];
}

// --- Histogram --------------------------------------------------------

- (void) drawHistogram
{
	NSRect b = [self bounds];
	CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];

	CGFloat leftLabelW   = 38.0;
	CGFloat rightLabelW  = 38.0;
	CGFloat topMargin    = ColorPlotWaveformTopMargin;
	CGFloat bottomMargin = ColorPlotWaveformBottomMargin;

	NSRect chart = NSMakeRect(leftLabelW,
	                          bottomMargin,
	                          b.size.width  - leftLabelW - rightLabelW,
	                          b.size.height - topMargin  - bottomMargin);
	if (chart.size.width < 4 || chart.size.height < 4) return;

	BOOL isHDR = snapshot.isHDR;
	float xMax = isHDR ? kHDRWaveformMax : 1.0f;

	size_t pxW = (size_t)floor(chart.size.width);
	size_t pxH = (size_t)floor(chart.size.height);
	if (pxW == 0 || pxH == 0) return;

	// Histogram has a fixed 256 bins (matches 8-bit SDR data exactly; plenty of
	// resolution for HDR). The chart is then formed by aggregating bins into the
	// chart's pixel columns — no comb-teeth aliasing when pxW > 256.
	const size_t binCount = 256;
	uint32_t *binR = (uint32_t *)calloc(binCount, sizeof(uint32_t));
	uint32_t *binG = (uint32_t *)calloc(binCount, sizeof(uint32_t));
	uint32_t *binB = (uint32_t *)calloc(binCount, sizeof(uint32_t));
	if (!binR || !binG || !binB) {
		free(binR); free(binG); free(binB);
		return;
	}

	for (size_t y = 0; y < snapshot.height; ++y) {
		for (size_t x = 0; x < snapshot.width; ++x) {
			float r, g, bl;
			readPixel(&snapshot, x, y, &r, &g, &bl);
			int ir = (int)(r  / xMax * (float)binCount);
			int ig = (int)(g  / xMax * (float)binCount);
			int ib = (int)(bl / xMax * (float)binCount);
			if (ir >= 0 && ir < (int)binCount) binR[ir]++;
			if (ig >= 0 && ig < (int)binCount) binG[ig]++;
			if (ib >= 0 && ib < (int)binCount) binB[ib]++;
		}
	}

	// Smooth resampling from the 256-bin histogram into pxW float-valued chart
	// columns using a triangular kernel. When pxW > binCount this gives linear
	// interpolation between adjacent bin centers (removes the staircase). When
	// pxW < binCount the kernel widens and aggregates several bins.
	float *colR = (float *)calloc(pxW, sizeof(float));
	float *colG = (float *)calloc(pxW, sizeof(float));
	float *colB = (float *)calloc(pxW, sizeof(float));
	if (!colR || !colG || !colB) {
		free(binR); free(binG); free(binB);
		free(colR); free(colG); free(colB);
		return;
	}
	double kernelHalf = (double)binCount / (double)pxW;
	if (kernelHalf < 1.0) kernelHalf = 1.0;
	for (size_t x = 0; x < pxW; ++x) {
		double center = ((double)x + 0.5) * binCount / (double)pxW - 0.5;
		if (center < 0) center = 0;
		if (center > (double)binCount - 1) center = (double)binCount - 1;
		long bi_lo = (long)floor(center - kernelHalf);
		long bi_hi = (long)ceil(center + kernelHalf);
		if (bi_lo < 0) bi_lo = 0;
		if (bi_hi > (long)binCount - 1) bi_hi = (long)binCount - 1;
		double sR = 0, sG = 0, sB = 0;
		for (long bi = bi_lo; bi <= bi_hi; ++bi) {
			double dist = fabs((double)bi - center);
			double w = 1.0 - dist / kernelHalf;
			if (w <= 0) continue;
			sR += w * (double)binR[bi];
			sG += w * (double)binG[bi];
			sB += w * (double)binB[bi];
		}
		colR[x] = (float)sR;
		colG[x] = (float)sG;
		colB[x] = (float)sB;
	}
	free(binR); free(binG); free(binB);

	float maxCount = 0;
	for (size_t x = 0; x < pxW; ++x) {
		if (colR[x] > maxCount) maxCount = colR[x];
		if (colG[x] > maxCount) maxCount = colG[x];
		if (colB[x] > maxCount) maxCount = colB[x];
	}

	// Draw the axis grid + labels FIRST, so that the bars (drawn next) hide the
	// gridlines wherever they have data — avoids the visual "double line" at any
	// tick position that lines up with a histogram spike.
	{
		NSColor *labelColor = [NSColor colorWithCalibratedWhite:0.80 alpha:1.0];
		NSColor *gridColor  = [NSColor colorWithCalibratedWhite:0.45 alpha:1.0];
		NSFont  *labelFont  = [NSFont systemFontOfSize:10.0];
		NSDictionary *labelAttrs = @{
			NSFontAttributeName: labelFont,
			NSForegroundColorAttributeName: labelColor
		};

		[gridColor setStroke];
		NSBezierPath *grid = [NSBezierPath bezierPath];
		[grid setLineWidth:0.5];

		const int nTicks = 5;
		for (int i = 0; i <= nTicks - 1; ++i) {
			CGFloat frac = (CGFloat)i / (nTicks - 1);
			CGFloat x = chart.origin.x + frac * chart.size.width;
			[grid moveToPoint:NSMakePoint(x, chart.origin.y)];
			[grid lineToPoint:NSMakePoint(x, chart.origin.y + chart.size.height)];
			NSString *lab;
			if (isHDR) {
				lab = [NSString stringWithFormat:@"%.1f", frac * (double)xMax];
			} else {
				lab = [NSString stringWithFormat:@"%d", (int)round(frac * 255.0)];
			}
			NSSize ls = [lab sizeWithAttributes:labelAttrs];
			CGFloat lx = x - ls.width / 2.0;
			CGFloat ly = chart.origin.y - ls.height - 2.0;
			[lab drawAtPoint:NSMakePoint(lx, ly) withAttributes:labelAttrs];
		}
		[grid stroke];

		NSBezierPath *border = [NSBezierPath bezierPathWithRect:chart];
		[border setLineWidth:0.5];
		[border stroke];
	}

	if (maxCount == 0) {
		free(colR); free(colG); free(colB);
		return;
	}

	// Render bars into an RGBA8 bitmap with additive blending, drawn on top of
	// the (already-drawn) grid so spikes hide the gridline they overlap. Each
	// channel uses a softer Lightroom-inspired tint instead of pure R/G/B; the
	// bar top is anti-aliased via fractional coverage for smooth curve outlines.
	size_t bytesPerRow = pxW * 4;
	uint8_t *bmp = calloc(pxH, bytesPerRow);
	if (!bmp) {
		free(colR); free(colG); free(colB);
		return;
	}

	// Lightroom-ish channel tints (each tint is added to the running total when
	// its bar covers the current row). Three-way overlap saturates toward white.
	const double kRedR   = 215.0, kRedG   = 100.0, kRedB   = 110.0;
	const double kGreenR = 100.0, kGreenG = 175.0, kGreenB = 110.0;
	const double kBlueR  = 100.0, kBlueG  = 130.0, kBlueB  = 215.0;

	const double invMax = 1.0 / (double)maxCount;
	const double pxHd   = (double)pxH;
	for (size_t x = 0; x < pxW; ++x) {
		double hR_f = (double)colR[x] * invMax * pxHd;
		double hG_f = (double)colG[x] * invMax * pxHd;
		double hB_f = (double)colB[x] * invMax * pxHd;

		for (size_t row = 0; row < pxH; ++row) {
			double fromBottom = (double)(pxH - 1 - row);

			// Coverage in [0,1]: 1 below the bar's top, fractional at the edge, 0 above.
			double covR = hR_f - fromBottom;
			double covG = hG_f - fromBottom;
			double covB = hB_f - fromBottom;
			if (covR > 1.0) covR = 1.0; else if (covR < 0.0) covR = 0.0;
			if (covG > 1.0) covG = 1.0; else if (covG < 0.0) covG = 0.0;
			if (covB > 1.0) covB = 1.0; else if (covB < 0.0) covB = 0.0;

			uint8_t *p = bmp + row * bytesPerRow + x * 4;
			if (covR == 0.0 && covG == 0.0 && covB == 0.0) {
				p[3] = 0;
				continue;
			}
			double r = covR * kRedR   + covG * kGreenR + covB * kBlueR;
			double g = covR * kRedG   + covG * kGreenG + covB * kBlueG;
			double b = covR * kRedB   + covG * kGreenB + covB * kBlueB;
			if (r > 255.0) r = 255.0;
			if (g > 255.0) g = 255.0;
			if (b > 255.0) b = 255.0;
			p[0] = (uint8_t)(r + 0.5);
			p[1] = (uint8_t)(g + 0.5);
			p[2] = (uint8_t)(b + 0.5);
			p[3] = 255;
		}
	}

	free(colR); free(colG); free(colB);

	CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
	CGContextRef bmpCtx = CGBitmapContextCreate(bmp, pxW, pxH, 8, bytesPerRow,
		cs, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
	CGImageRef img = bmpCtx ? CGBitmapContextCreateImage(bmpCtx) : NULL;
	if (img) {
		CGContextDrawImage(ctx, chart, img);
		CGImageRelease(img);
	}
	if (bmpCtx) CGContextRelease(bmpCtx);
	CGColorSpaceRelease(cs);
	free(bmp);
}

@end
