1. Dive #56 (2025): Cathedrals

    Dive log

    Stats

    • Max Depth: 25.4 m / 83 ft
    • Average Depth: 17.8 m / 58 ft
    • Duration: 56 minutes
    • Water Temp: 12.0°C / 54°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: 234 bar / 3400 psi
    • Pressure End: 34 bar / 500 psi
    • Surface Consumption Rate: 15.51 L/min / 0.55 cf/min

    Notes

    Second dive of the day aboard the Escapade, following the morning dive at McDonald’s. The group decided to keep the swim-through theme going and opted for Cathedral, a site just offshore of Pescadero Point on 17-Mile Drive.

    We descended the mooring line and dropped straight to 55 feet, finding the first swim-through almost immediately. It was a bit narrower than the ones at McDonald’s but still had plenty of room to navigate. After exiting, we spent some time exploring the rock wall. About 30 minutes in, having spent most of the dive below 60 feet, I was definitely starting to feel the cold.

    I was about to signal to my buddy that I was ready to ascend when another diver motioned that they’d found a second swim-through. We finned over to explore it, then began a slow ascent back to the boat. I was definitely cold by the end, and my gas consumption proved it: my SCR notched up to 55 cf/min, way higher than my typical rate.


  2. Dive #55 (2025): McDonalds

    Dive log

    Stats

    • Max Depth: 30.0 m / 98 ft
    • Average Depth: 20.7 m / 68 ft
    • Duration: 31 minutes
    • Water Temp: 12.0°C / 54°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: 234 bar / 3400 psi
    • Pressure End: 103 bar / 1500 psi
    • Surface Consumption Rate: 16.63 L/min / 0.59 cf/min

    Notes

    After not making it around Point Pinos the past weekend, I was stoked to finally make it to the Carmel side. While I’ve dived at Point Lobos and Monastery, this was my first boat dive on the Carmel side of the Monterey coast. Given the calm conditions, a few of the more experienced BAUE divers and the captain decided on a spot that hadn’t been visited in a while: McDonald’s. South of Point Lobos, this dive site features a couple of beautiful swim-throughs, starting in about 70’ of water. Anchoring here is tricky, with the boat sitting just outside a submerged rock. The captain gave us a briefing on what to do if the anchor slips during our dive.

    The swim-throughs were incredible—small enough to require careful finning but large enough to give a gorgeous view out the other side. This was helped by great visibility, up to 40’ at times.

    The kelp was thinner than I expected, something the crew attributed to a healthy urchin population. Still, we found a large school of rockfish in the shallows around 30 feet, plus plenty of crabs and nudibranchs throughout the rock formations. We had to cut the dive a bit short after a buddy’s Shearwater transmitter stopped working. With no backup pressure gauge, it was the right and only call to make. Honestly, being the only one in a wetsuit, I wasn’t upset about heading up before the cold really started to set in.


  3. Dive #54 (2025): Eric's Pinnacle

    Dive log

    Stats

    • Max Depth: 18.7 m / 61 ft
    • Average Depth: 12.5 m / 41 ft
    • Duration: 52 minutes
    • Water Temp: 12.0°C / 54°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: 241 bar / 3500 psi
    • Pressure End: 103 bar / 1500 psi
    • Surface Consumption Rate: 14.27 L/min / 0.50 cf/min

    Notes

    No notes recorded for this dive.


  4. Dive #53 (2025): Redfish Ridge

    Dive log

    A lincod at Redfish Ridge

    Stats

    • Max Depth: 21.3 m / 70 ft
    • Average Depth: 17.8 m / 58 ft
    • Duration: 46 minutes
    • Water Temp: 12.0°C / 54°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: 241 bar / 3500 psi
    • Pressure End: 69 bar / 1000 psi
    • Surface Consumption Rate: 16.28 L/min / 0.57 cf/min

    Notes

    First dive of the day from the Escapade with BAUE. We tried to make it around to Carmel but turned back to due larger than expected swells right at the navigational buoy marking Point Pinos. Oh well, we’ll get there eventually. Had a more successful dive here than my first time around. Lots of Redfish (what a shock!), lincold, copper rockfish, vermillion rockfish, and more.


  5. Building An Automated Photo Gallery With Astro.js

    When I rebuilt my website using Astro.js, a primary goal was to make publishing my photos easier. Because of the ease of publishing on social media platforms, I had fallen into the trap of only posting there. Inevitably, when you post on social media, you evaluate your work through the lens of engagement. This is not my goal. I create photos for myself. For the sake of the creative process. And to watch my progress over time.

    The majority of my photo workflow is in Capture One, my favorite RAW editor. It occurred to me: why can’t I just export a photo from Capture One and have it publish to my website?. So I set off to build that workflow as closely as possible.

    My goals were:

    1. Organize my photos into galleries for frequent subjects like Landscapes or Birds & Wildlife
    2. Give each photo a permanent URL to make it easy to share and link to
    3. Publish EXIF data with my photos

    While I don’t have a computer science background, I’m quite happy with the solution I built. I’m sure the code below could be improved (please share if you have ideas!). But for now, this code works well for me and provides a starting point for anyone interested in a similar setup.

    The Code

    The system has three components:

    1. A nodemon process that watches for changes to my src/photography directory
    2. A JavaScript script that reads EXIF data from an image, and is called by nodemon when a new image is detected. This script saves EXIF data to a json file that Astro.js files are able to process
    3. Astro.js templates that render a gallery (e.g. landscapes) and a single image view for each photo

    Let’s go through them step-by-step.

    package.json

    The process begins with nodemon, a simple utility that uses concurrently to watch for new images in my src/photography directory. As soon as a new image appears (e.g. .jpg), it triggers the automation.

    {
      "name": "astro-photography-site",
      "type": "module",
      "version": "1.0.0",
      "scripts": {
        "dev": "concurrently \"astro dev\" \"nodemon -L --watch 'src/photography/**' --ext 'jpg,jpeg,png,gif' --exec 'node image-exif.js'\"",
        "build": "astro build",
        "preview": "astro preview"
      },
      "dependencies": {
        "@astrojs/mdx": "^4.1.0",
        "astro": "^5.3.1",
        "exifreader": "^4.26.1"
      },
      "devDependencies": {
        "concurrently": "^9.1.2",
        "nodemon": "^3.1.9"
      }
    }

    Extracting metadata from images

    This is where the system becomes an extension of my editing workflow. After nodemon invokes the script, it recursively walks through the src/photography directory and uses the exifreader library to pull out the metadata I’ve already embedded in Capture One. The script then writes all of that data to a single JSON file, src/image-data.json, which becomes our on-disk database. This gives Astro.js everything it needs to render rich, dynamic templates.

    import * as fs from 'node:fs/promises';
    import path from 'node:path';
    import { fileURLToPath } from 'url';
    import { createRequire } from 'module';
    const require = createRequire(import.meta.url);
    const EXIF = require('exifreader');
    
    /**
     * Extracts EXIF data from an image file.
     * @param {string} imagePath - The path to the image file.
     * @returns {Promise<object|null>} The EXIF tags or null if an error occurs.
     */
    async function extractEXIFData(imagePath) {
        try {
            const imageBuffer = await fs.readFile(imagePath);
            const tags = EXIF.load(imageBuffer);
            return tags;
        } catch (error) {
            console.error(`Error processing ${imagePath}:`, error);
            return null;
        }
    }
    
    /**
     * Traverses the photography directory, extracts EXIF data, and writes it to a JSON file.
     */
    async function processImages() {
        const imageData = {};
        const photographyDir = 'src/photography';
        const outputFilePath = 'src/image-data.json';
    
        async function traverseDir(dir) {
            const files = await fs.readdir(dir, { withFileTypes: true });
    
            for (const file of files) {
                const fullPath = path.join(dir, file.name);
    
                if (file.isDirectory()) {
                    await traverseDir(fullPath); // Recursive call for subdirectories
                } else if (/\.(jpg|jpeg|png|gif)$/i.test(file.name)) {
                    const exifData = await extractEXIFData(fullPath);
                    if (exifData) {
                        imageData[fullPath] = exifData;
                    }
                }
            }
        }
    
        await traverseDir(photographyDir);
    
        await fs.writeFile(outputFilePath, JSON.stringify(imageData, null, 2));
        console.log(`EXIF data written to ${outputFilePath}`);
    }
    
    processImages();

    With the JSON file in place, Astro components can read that data and use it to display content.

    The gallery page uses import.meta.glob to find all of the images within a given gallery directory (e.g. landscapes or travel) and then uses the image path to look up the correct EXIF data from our JSON file.

    ---
    import { Picture } from 'astro:assets';
    import imageData from '../../image-data.json';
    
    // Get a list of all images from the "photography" directory.
    const allImages = import.meta.glob<{ default: ImageMetadata }>('../../photography/*/*.{jpg,jpeg,png,gif}', { eager: true });
    
    // Process the image list to be used in the template.
    const imageList = Object.keys(allImages).map(path => {
      const imageModule = allImages[path];
      const parts = path.split('/');
      const slugWithExtension = parts.pop();
      const slug = slugWithExtension ? slugWithExtension.split('.')[0] : '';
      const alt = slug || "Gallery Image";
    
      return {
        src: imageModule.default,
        alt: alt,
        slug: slug,
        imagePath: `src/photography/${parts.pop()}/${slugWithExtension}`,
      };
    });
    
    // Define a type for the imageData JSON
    interface ImageData {
      [key: string]: {
        Headline?: {
          description?: string;
        };
        [key: string]: any;
      };
    }
    const typedImageData: ImageData = imageData;
    
    function getImageOrientation(width: number, height: number): 'portrait' | 'landscape' {
      return width > height ? 'landscape' : 'portrait';
    }
    ---
    
    <h1>My Photo Gallery</h1>
    <ul class="photo-grid">
      {imageList.map((image) => {
        const imageEXIF = typedImageData[image.imagePath];
        const orientation = getImageOrientation(image.src.width, image.src.height);
    
        return (
          <li class={`photo-item ${orientation}`}>
            <a href={`/photography/${image.slug}/`}>
              <figure>
                <Picture src={image.src}
                         formats={['avif', 'webp']}
                         alt={imageEXIF?.Headline?.description ?? 'View'} />
                <figcaption>
                  {imageEXIF?.Headline?.description ?? 'View Image'}
                </figcaption>
              </figure>
            </a>
          </li>
        );
      })}
    </ul>

    Single Image View

    It’s important to me that each image has a permanent URL so I can easily link to the image from elsewhere. Such as:

    https://conragan.com/photography/birds-wildlife/coyote-stalking/

    The code that handles that page is:

    ---
    import { Image } from 'astro:assets';
    import imageData from '../../../image-data.json';
    
    // Get the props passed from getStaticPaths.
    const { slug, imagePath, imageModule } = Astro.props;
    
    const typedImageData = imageData as any;
    
    // Retrieve image EXIF data using the imagePath.
    const imageEXIF = typedImageData[imagePath];
    ---
    
    <div id="photo-container">
      <div id="photo-image">
        <Image src={imageModule.default} alt={imageEXIF?.Headline?.description ?? slug} width="1200"/>
      </div>
      <div id="photo-content">
        <h1 class="photo-title">{imageEXIF?.Headline?.description ?? slug}</h1>
        <dl id="exif">
          {imageEXIF?.Make?.description && (
            <>
              <dt>Camera</dt>
              <dd>{imageEXIF.Make.description} {imageEXIF.Model?.description}</dd>
            </>
          )}
          {imageEXIF?.LensModel?.description && (
            <>
              <dt>Lens</dt>
              <dd>{imageEXIF.LensModel.description}</dd>
            </>
          )}
          {imageEXIF?.FocalLength?.description && (
            <>
              <dt>Focal Length</dt>
              <dd>{imageEXIF.FocalLength.description}</dd>
            </>
          )}
          {imageEXIF?.FNumber?.description && (
            <>
              <dt>Aperture</dt>
              <dd>{imageEXIF.FNumber.description}</dd>
            </>
          )}
          {imageEXIF?.ExposureTime?.description && (
            <>
              <dt>Exposure Time</dt>
              <dd>{imageEXIF.ExposureTime.description}</dd>
            </>
          )}
          {imageEXIF?.ISOSpeedRatings?.description && (
            <>
              <dt>ISO</dt>
              <dd>{imageEXIF.ISOSpeedRatings.description}</dd>
            </>
          )}
          {imageEXIF?.DateTimeOriginal?.value && (
            <>
              <dt>Captured</dt>
              <dd>{imageEXIF.DateTimeOriginal.value[0]}</dd>
            </>
          )}
        </dl>
      </div>
    </div>

    That’s the system in full. I’ve simplified some of the examples above by removing extra code pertaining to style and presentation. If you want to view the code in full, head on over to my Github repo. If you end up using this, I’d love to hear about it and check out your photography as well!

    In a future blog post I’ll cover how I handle some of the front-end rendering. In particular how I was able to provide responsive layouts for different clients, as well as how I used JavaScript to provide streamlined navigation.



  6. Reading: Yumi and the Nightmare Painter

    Reading Yumi and the Nightmare Painter by Brandon Sanderson

    Cover of Yumi and the Nightmare Painter

    When you finish a Brandon Sanderson book, it’s hard to leave the Cosmere. After Wind and Truth, picking up one of the Secret Projects books (I absolutely loved Tress of the Emerald Sea last year).


  7. Dive #52 (2025): Breakwater Beach

    Dive log

    Stats

    • Max Depth: 6.3 m / 21 ft
    • Average Depth: 2.7 m / 9 ft
    • Duration: 10 minutes
    • Water Temp: 14.0°C / 57°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  8. Dive #51 (2025): Breakwater Beach

    Dive log

    Stats

    • Max Depth: 8.8 m / 29 ft
    • Average Depth: 7.1 m / 23 ft
    • Duration: 17 minutes
    • Water Temp: 13.0°C / 55°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  9. Dive #50 (2025): Breakwater Beach

    Dive log

    Stats

    • Max Depth: 7.4 m / 24 ft
    • Average Depth: 5.9 m / 19 ft
    • Duration: 31 minutes
    • Water Temp: 13.0°C / 55°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  10. Dive #49 (2025): Redfish Ridge

    Dive log

    Stats

    • Max Depth: 20.7 m / 68 ft
    • Average Depth: 17.7 m / 58 ft
    • Duration: 48 minutes
    • Water Temp: 11.0°C / 52°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  11. Dive #48 (2025): Ball Buster Pinnacle

    Dive log

    Stats

    • Max Depth: 29.1 m / 96 ft
    • Average Depth: 20.5 m / 67 ft
    • Duration: 40 minutes
    • Water Temp: 11.0°C / 52°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  12. Dive #47 (2025): Metridium Fields

    Dive log

    Stats

    • Max Depth: 15.3 m / 50 ft
    • Average Depth: 8.5 m / 28 ft
    • Duration: 63 minutes
    • Water Temp: 13.0°C / 55°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.


  13. Dive #46 (2025): Breakwater Beach

    Dive log

    Stats

    • Max Depth: 13.4 m / 44 ft
    • Average Depth: 9.7 m / 32 ft
    • Duration: 51 minutes
    • Water Temp: 13.0°C / 55°F
    • Gas: EAN32
    • Tank(s): HP100
    • Pressure Start: N/A
    • Pressure End: N/A
    • Surface Consumption Rate: N/A

    Notes

    No notes recorded for this dive.



  14. Reading: Wind and Truth

    Reading Wind and Truth by Brandon Sanderson

    Cover of Wind and Truth

    This will probably destory my read count for the year, but who cares. Can’t believe the conclusion to the first arc of Brandon Sanderson’s The Stormlight Archive is finally here.


  15. Dive #45 (2025): Chikin Ha Cenote

    Dive log

    Stats

    • Max Depth: 14.6 m / 48 ft
    • Average Depth: 7.4 m / 24 ft
    • Duration: 51 minutes
    • Water Temp: 25.0°C / 77°F
    • Gas: EAN32
    • Tank(s): AL80
    • Pressure Start: 214 bar / 3100 psi
    • Pressure End: 103 bar / 1500 psi
    • Surface Consumption Rate: 13.77 L/min / 0.49 cf/min

    Notes

    No notes recorded for this dive.


  16. Dive #44 (2025): Chikin Ha Cenote

    Dive log

    Stats

    • Max Depth: 12.5 m / 41 ft
    • Average Depth: 6.7 m / 22 ft
    • Duration: 41 minutes
    • Water Temp: 25.0°C / 77°F
    • Gas: EAN32
    • Tank(s): AL80
    • Pressure Start: 214 bar / 3100 psi
    • Pressure End: 138 bar / 2000 psi
    • Surface Consumption Rate: 12.25 L/min / 0.43 cf/min

    Notes

    No notes recorded for this dive.


  17. Dive #43 (2025): Nohoch Na Chich

    Dive log

    Stats

    • Max Depth: 6.4 m / 21 ft
    • Average Depth: 3.5 m / 12 ft
    • Duration: 77 minutes
    • Water Temp: 26.0°C / 79°F
    • Gas: EAN32
    • Tank(s): AL80
    • Pressure Start: 221 bar / 3200 psi
    • Pressure End: 100 bar / 1450 psi
    • Surface Consumption Rate: 12.87 L/min / 0.45 cf/min

    Notes

    No notes recorded for this dive.