{{ theme.skipToContentLabel || 'Skip to content' }}

Heatmap Value Filter Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a flexible value filter with 7 modes (=, ≠, >, ≥, <, ≤, Range) to filter PM10 data points in the Heatmap.

Architecture: Extend HeatmapControlsState with filter properties, add filtering logic to filteredRows useMemo, and create a collapsible UI section with button group and input fields.

Tech Stack: React, TypeScript, existing UI patterns (button group, toggle switch, input fields)


Task 1: Add Type Definitions

Files:

  • Modify: src/features/heatmap/types.ts

Step 1: Add ValueFilterMode type

Add after the existing type definitions (after line 7):

typescript
export type ValueFilterMode = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'range';

Step 2: Extend HeatmapControlsState

Add the new properties at the end of HeatmapControlsState (before the closing }):

typescript
  // Data filtering
  hideZeroValues: boolean;
  // Value filter
  valueFilterEnabled: boolean;
  valueFilterMode: ValueFilterMode;
  valueFilterValue: number | null;
  valueFilterMin: number | null;
  valueFilterMax: number | null;

Step 3: Commit

bash
git add src/features/heatmap/types.ts
git commit -m "feat(heatmap): add ValueFilterMode type and extend HeatmapControlsState"

Task 2: Add Default Values

Files:

  • Modify: src/features/heatmap/constants.ts

Step 1: Update DEFAULT_CONTROLS

Add the new default values at the end of DEFAULT_CONTROLS (after hideZeroValues: false):

typescript
  // Data filtering
  hideZeroValues: false, // Show all data by default
  // Value filter
  valueFilterEnabled: false,
  valueFilterMode: 'eq' as const,
  valueFilterValue: null,
  valueFilterMin: null,
  valueFilterMax: null,

Step 2: Verify TypeScript compiles

Run: pnpm build:check Expected: PASS

Step 3: Commit

bash
git add src/features/heatmap/constants.ts
git commit -m "feat(heatmap): add value filter defaults to DEFAULT_CONTROLS"

Task 3: Update Filter Logic

Files:

  • Modify: src/app/(admin)/(pages)/heatmap/index.tsx

Step 1: Update filteredRows useMemo

Replace the existing filteredRows useMemo with the enhanced version that includes value filtering:

typescript
// Filter out zero values and apply value filter
const filteredRows = useMemo(() => {
  let result = rows;

  // 1. Apply hideZeroValues filter first
  if (controls.hideZeroValues) {
    result = result.filter((row) => {
      const value = parseFloat(row.Location_AssetValue);
      return !isNaN(value) && value > 0;
    });
  }

  // 2. Apply value filter if enabled and has valid value
  if (controls.valueFilterEnabled) {
    const filterValue = controls.valueFilterValue;
    const filterMin = controls.valueFilterMin;
    const filterMax = controls.valueFilterMax;
    const mode = controls.valueFilterMode;

    // Check if filter has valid values
    const hasValidSingleValue = filterValue !== null && !isNaN(filterValue);
    const hasValidRangeValues = filterMin !== null && filterMax !== null &&
                                 !isNaN(filterMin) && !isNaN(filterMax) &&
                                 filterMin <= filterMax;

    if ((mode !== 'range' && hasValidSingleValue) || (mode === 'range' && hasValidRangeValues)) {
      result = result.filter((row) => {
        const value = parseFloat(row.Location_AssetValue);
        if (isNaN(value)) return false;

        switch (mode) {
          case 'eq':    return value !== filterValue;  // Filter out equals
          case 'neq':   return value === filterValue;  // Filter out not equals (keep equals)
          case 'gt':    return value <= filterValue!;  // Filter out greater than
          case 'gte':   return value < filterValue!;   // Filter out greater than or equal
          case 'lt':    return value >= filterValue!;  // Filter out less than
          case 'lte':   return value > filterValue!;   // Filter out less than or equal
          case 'range': return value < filterMin! || value > filterMax!;  // Keep only in range
          default:      return true;
        }
      });
    }
  }

  return result;
}, [rows, controls.hideZeroValues, controls.valueFilterEnabled,
    controls.valueFilterMode, controls.valueFilterValue,
    controls.valueFilterMin, controls.valueFilterMax]);

Step 2: Verify TypeScript compiles

Run: pnpm build:check Expected: PASS

Step 3: Commit

bash
git add "src/app/(admin)/(pages)/heatmap/index.tsx"
git commit -m "feat(heatmap): add value filter logic to filteredRows useMemo"

Task 4: Add Value Filter UI

Files:

  • Modify: src/app/(admin)/(pages)/heatmap/index.tsx

Step 1: Add validation state

After the existing state declarations (around line 140), add:

typescript
const [valueFilterError, setValueFilterError] = useState<string | null>(null);

Step 2: Add validation effect

Add a useEffect to validate the filter inputs:

typescript
// Validate value filter inputs
useEffect(() => {
  if (!controls.valueFilterEnabled) {
    setValueFilterError(null);
    return;
  }

  if (controls.valueFilterMode === 'range') {
    if (controls.valueFilterMin === null || controls.valueFilterMax === null) {
      setValueFilterError('Please enter valid numbers');
    } else if (controls.valueFilterMin > controls.valueFilterMax) {
      setValueFilterError('Min must be less than or equal to Max');
    } else {
      setValueFilterError(null);
    }
  } else {
    if (controls.valueFilterValue === null) {
      setValueFilterError('Please enter a valid number');
    } else {
      setValueFilterError(null);
    }
  }
}, [controls.valueFilterEnabled, controls.valueFilterMode,
    controls.valueFilterValue, controls.valueFilterMin, controls.valueFilterMax]);

Step 3: Add helper for counting value-filtered points

Add this after the validation effect:

typescript
// Calculate how many points are filtered by value filter only
const valueFilteredCount = useMemo(() => {
  if (!controls.valueFilterEnabled) return 0;

  // Start from rows after hideZeroValues filter
  let baseRows = rows;
  if (controls.hideZeroValues) {
    baseRows = rows.filter((row) => {
      const value = parseFloat(row.Location_AssetValue);
      return !isNaN(value) && value > 0;
    });
  }

  return baseRows.length - filteredRows.length;
}, [rows, filteredRows, controls.hideZeroValues, controls.valueFilterEnabled]);

Step 4: Add the UI component

Find the "Hide Zero Values Toggle" section in the JSX. After the closing of that section (after the {controls.hideZeroValues && (...)} block), add the Value Filter UI:

tsx
{/* Value Filter */}
<div className="space-y-3 pt-3 border-t border-slate-200 dark:border-slate-700">
  <div className="flex items-center justify-between">
    <div className="flex items-center gap-2">
      <Icon
        className="w-4 h-4 text-slate-600 dark:text-slate-400"
        icon="solar:tuning-2-bold-duotone"
      />
      <label className="text-sm font-medium text-slate-700 dark:text-slate-300">
        Value Filter
      </label>
    </div>
    <button
      type="button"
      role="switch"
      aria-checked={controls.valueFilterEnabled}
      onClick={() => updateControls({ valueFilterEnabled: !controls.valueFilterEnabled })}
      className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
        controls.valueFilterEnabled
          ? "bg-blue-600"
          : "bg-slate-300 dark:bg-slate-600"
      }`}
    >
      <span
        className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
          controls.valueFilterEnabled ? "translate-x-6" : "translate-x-1"
        }`}
      />
    </button>
  </div>

  {/* Filter Mode Buttons */}
  <div className={`flex flex-wrap gap-1 ${!controls.valueFilterEnabled ? 'opacity-50' : ''}`}>
    {([
      { mode: 'eq', label: '=' },
      { mode: 'neq', label: '≠' },
      { mode: 'gt', label: '>' },
      { mode: 'gte', label: '≥' },
      { mode: 'lt', label: '<' },
      { mode: 'lte', label: '≤' },
      { mode: 'range', label: 'Range' },
    ] as const).map(({ mode, label }) => (
      <button
        key={mode}
        type="button"
        disabled={!controls.valueFilterEnabled}
        onClick={() => updateControls({ valueFilterMode: mode })}
        className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
          controls.valueFilterMode === mode
            ? 'bg-blue-600 text-white'
            : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600'
        } ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
      >
        {label}
      </button>
    ))}
  </div>

  {/* Input Fields */}
  {controls.valueFilterMode === 'range' ? (
    <div className={`flex gap-2 ${!controls.valueFilterEnabled ? 'opacity-50' : ''}`}>
      <input
        type="number"
        placeholder="Min"
        disabled={!controls.valueFilterEnabled}
        value={controls.valueFilterMin ?? ''}
        onChange={(e) => {
          const val = e.target.value === '' ? null : parseFloat(e.target.value);
          updateControls({ valueFilterMin: val });
        }}
        className={`flex-1 px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
          valueFilterError && controls.valueFilterEnabled
            ? 'border-red-500'
            : 'border-slate-300 dark:border-slate-600'
        } ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
      />
      <input
        type="number"
        placeholder="Max"
        disabled={!controls.valueFilterEnabled}
        value={controls.valueFilterMax ?? ''}
        onChange={(e) => {
          const val = e.target.value === '' ? null : parseFloat(e.target.value);
          updateControls({ valueFilterMax: val });
        }}
        className={`flex-1 px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
          valueFilterError && controls.valueFilterEnabled
            ? 'border-red-500'
            : 'border-slate-300 dark:border-slate-600'
        } ${!controls.valueFilterEnabled ? 'cursor-not-allowed' : ''}`}
      />
    </div>
  ) : (
    <input
      type="number"
      placeholder="Enter value..."
      disabled={!controls.valueFilterEnabled}
      value={controls.valueFilterValue ?? ''}
      onChange={(e) => {
        const val = e.target.value === '' ? null : parseFloat(e.target.value);
        updateControls({ valueFilterValue: val });
      }}
      className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
        valueFilterError && controls.valueFilterEnabled
          ? 'border-red-500'
          : 'border-slate-300 dark:border-slate-600'
      } ${!controls.valueFilterEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
    />
  )}

  {/* Error Message */}
  {valueFilterError && controls.valueFilterEnabled && (
    <p className="text-xs text-red-500">{valueFilterError}</p>
  )}

  {/* Filter Info */}
  {controls.valueFilterEnabled && !valueFilterError && valueFilteredCount > 0 && (
    <p className="text-xs text-slate-500 dark:text-slate-400">
      Filtering out {valueFilteredCount} data points
    </p>
  )}
</div>

Step 5: Verify TypeScript compiles

Run: pnpm build:check Expected: PASS

Step 6: Commit

bash
git add "src/app/(admin)/(pages)/heatmap/index.tsx"
git commit -m "feat(heatmap): add Value Filter UI with mode buttons and validation"

Task 5: Build Verification and Testing

Step 1: Run full build

Run: pnpm build:check Expected: PASS

Step 2: Manual testing checklist

  1. Navigate to Heatmap page
  2. Verify "Value Filter" section appears below "Hide Zero Values"
  3. Test toggle enable/disable - buttons and inputs should gray out when disabled
  4. Test each filter mode button - should highlight when selected
  5. Test single value input - enter a number, verify filtering works
  6. Test range mode - switch to Range, verify two inputs appear
  7. Test validation:
    • Empty input shows error
    • Range with Min > Max shows error
  8. Test with "Hide Zero Values" enabled - both filters should stack
  9. Verify filtered count displays correctly

Step 3: Final commit (if any fixes needed)

bash
git add -A
git commit -m "feat(heatmap): complete Value Filter feature"

Summary

This implementation adds a powerful value filter to the Heatmap with:

  1. 7 filter modes: =, ≠, >, ≥, <, ≤, Range
  2. Intuitive UI: Button group for mode selection, appropriate input fields
  3. Real-time validation: Error messages for invalid inputs
  4. Stacking filters: Works independently with "Hide Zero Values"
  5. User feedback: Shows count of filtered data points

Total estimated time: 25-30 minutes