Chart

A simple application that displays a barchart using Quartz Composer.

Sources

AppController.py

import objc
from objc import super
import Cocoa
import Quartz

# APPLICATION DATA STORAGE NOTES:
# - This application uses a simple data storage as an array of entries,
#   each containing two attributes: a label and a value
# - The array is represented as a NSMutableArray, the entries as
#   NSMutableDictionaries, the label as a NSString with the "label" key and
#   the value as a NSNumber with the "value" key
#
#
# QUARTZ COMPOSER COMPOSITION NOTES:
# - The enclosed Quartz Composer composition renders a 3D bars chart and is
#   loaded on the QCView in the application's window
# - This composition has three input parameters:
#   * "Data": the data to display by the chart which must be formatted as a
#     NSArray of NSDictionaries, each NSDictionary containing "label" / NSString
#     and "value" / NSNumber value-key pairs
#   * "Scale": a NSNumber used to scale the chart bars
#   * "Spacing": a NSNumber indicating the extra spacing between the chart bars
# - The "Data" and "Scale" input parameters are set programmatically while the
#   "Spacing" is set directly from the UI through Cocoa bindings
# - Note that this composition is quite simple and has the following
#   limitations:
#   * it may have rendering artifacts when looking at the chart from some angles
#   * it does not support negative values
#   * labels are not truncated if too long
# - Basically, the composition performs the following:
#   * renders a background gradient
#   * draws three planes on the X, Y and Z axes
#   * uses an Iterator patch to loop on the chart data, which is available as
#     a Structure, and for each member, retrieves the label and value, then
#     draws them
#   * the chart rendering is enclosed into a Camera macro patch used to center
#     it in the view
#   * the Camera macro patch is itself enclosed into a TrackBall macro patch
#     so that the user can rotate the chart with the mouse
#   * the TrackBall macro patch is itself enclosed into a Lighting macro patch
#     so that the chart is lighted
# - This composition makes uses of transparency for a nicer effect, but
#   neither OpenGL nor Quartz Composer handle automatically proper rendering
#   of mixed opaque and transparent 3D objects
# - A simple, but not fail-proof, algorithm to render opaque and transparent
#   3D objects is to:
#   * render opaque objects first with depth testing set to "Read / Write"
#   * render transparent objects with depth testing set to "Read-Only"
#
#
#  NIB FILES NOTES:
# - The QCView is configured to start rendering automatically and forward user
#   events (mouse events are required to rotate the chart)
# - An AppController instance is connected as the data source for the
#   NSTableView
# - The NSTableView is set up so that the identifiers of table columns match
#   the keys used in the data storage
# - The "Value" column of the NSTableView has a NSNumberFormatter which
#   guarantees only positive or null numbers can be entered here
# - The "Label" column of the NSTableView simply contains text
#

# Keys for the entries in the data storage
kDataKey_Label = "label" # NSString
kDataKey_Value = "value" # NSNumber

# Keys for the composition input parameters
kParameterKey_Data      = "Data"    # NSArray of NSDictionaries
kParameterKey_Scale     = "Scale"   # NSNumber
kParameterKey_Spacing   = "Spacing" # NSNumber

class AppController (Cocoa.NSObject):
    tableView = objc.IBOutlet()
    view = objc.IBOutlet()

    _data = objc.ivar()

    def init(self):
        # Allocate our data storage
        self = super(AppController, self).init()
        if self is None:
            return None

        self._data = []

        return self

    def awakeFromNib(self):
        # Load the composition file into the QCView (because this
        # QCView is bound to a QCPatchController in the nib file, this
        # will actually update the QCPatchController along with all the
        # bindings)
        if not self.view.loadCompositionFromFile_(Cocoa.NSBundle.mainBundle().pathForResource_ofType_("Chart", "qtz")):
            Cocoa.NSLog("Composition loading failed")
            Cocoa.NSApp.terminate_(None)

        # Populate data storage
        self._data.extend([
            {
                kDataKey_Label:"Palo Alto",
                kDataKey_Value: 2,
            },
            {
                kDataKey_Label: "Cupertino",
                kDataKey_Value: 1,
            },
            {
                kDataKey_Label: "Menlo Park",
                kDataKey_Value: 4,
            },
            {
                kDataKey_Label: "Mountain View",
                kDataKey_Value: 8,
            },
            {
                kDataKey_Label: "San Francisco",
                kDataKey_Value: 7,
            },
            {
                kDataKey_Label: "Los Altos",
                kDataKey_Value: 3,
            },
        ])

        #Initialize the views
        self.tableView.reloadData()
        self.updateChart()

    def updateChart(self):
        #Update the data displayed by the chart - it will be converted to a
        # Structure of Structures by Quartz Composer
        self.view.setValue_forInputKey_(self._data, kParameterKey_Data)

        #Compute the maximum value and set the chart scale accordingly
        max = 0.0
        for obj in self._data:
            value = obj[kDataKey_Value]
            if value > max:
                max = value

        if max == 0.0:
            scale = 1.0
        else:
            scale = 1/max
        self.view.setValue_forInputKey_(scale, kParameterKey_Scale)

    @objc.IBAction
    def addEntry_(self, sender):
        #Add a new entry to the data storage
        self._data.append({
            kDataKey_Label: "Untitled",
            kDataKey_Value: 0,
        })

        #Notify the NSTableView and update the chart
        self.tableView.reloadData()
        self.updateChart()

        #Automatically select and edit the new entry
        self.tableView.selectRow_byExtendingSelection_(len(self._data)-1, False)
        self.tableView.editColumn_row_withEvent_select_(
                self.tableView.columnWithIdentifier_(kDataKey_Label),
                len(self._data)-1, None, True)

    @objc.IBAction
    def removeEntry_(self, sender):
        #Make sure we have a valid selected row
        selectedRow = self.tableView.selectedRow()
        if selectedRow < 0 or self.tableView.editedRow() == selectedRow:
            return

        #Remove the currently selected entry from the data storage
        del self._data[selectedRow]

        #Notify the NSTableView and update the chart
        self.tableView.reloadData()
        self.updateChart()


    def numberOfRowsInTableView_(self, aTableView):
        # Return the number of entries in the data storage
        return len(self._data)

    def tableView_objectValueForTableColumn_row_(self, aTableView, aTableColumn, rowIndex):
        # Get the "label" or "value" attribute of the entry from the data
        # storage at index "rowIndex"
        return self._data[rowIndex][aTableColumn.identifier()]

    def tableView_setObjectValue_forTableColumn_row_(
            self, aTableView, anObject, aTableColumn, rowIndex):

        # Set the "label" or "value" attribute of the entry from the data
        # storage at index "rowIndex"
        self._data[rowIndex][aTableColumn.identifier()] = anObject

        # Update the chart
        self.updateChart()

main.py

from PyObjCTools import AppHelper

import AppController

AppHelper.runEventLoop()

setup.py

"""
Script for building the example.

Usage:
    python3 setup.py py2app
"""
from setuptools import setup

setup(
    name="PyChart",
    app=["main.py"],
    data_files=[
        "English.lproj",
        "Chart.qtz"
    ],
    setup_requires=[
        "py2app",
        "pyobjc-framework-Cocoa",
        "pyobjc-framework-Quartz",
    ]
)

Resources