Missing Fundamental something

Code to draw a graph of data. Each abscissa has an ordinate range to be displayed as a line segment. All data, i.e., scaled points are verified to be within the declared analysisView.bounds. strokeColors are verified within the range 0..1

BTW, no I don't need animation for this static data, but CALayer seemed to require more coding, and I found fewer code examples for it.

The code below has two problems:

1) it doesn't draw into the window

  1. the weird behavior of min/max

The first is why I am posting. What am I missing?

import AppKit

class AnalysisViewController: NSViewController {
    
    @IBOutlet var analysisView: NSView!
    
    var ranges                  = [ClosedRange<Double>]()
    var ordinateMinimum         = CGFloat()
    var ordinateMaximum         = CGFloat()
    var ordinateScale           = CGFloat()
    let abscissaMinimum:CGFloat = 1
    let abscissaMaximum:CGFloat = 92
    let abscissaScale  :CGFloat = 800/92
    let shapeLayer              = CAShapeLayer()
    var points                  = [CGPoint]()       //  created just to verify (in debugger area) that points are within analysisView.bounds

    func genrateGraph() {
//        ranges.append(0...0)                                        //  inexplicably FAILS! @ ordinateMinimum/ordinateMaximum     if replaces "if N == 1" below
//        ranges.append(0.1...0.1)                                    //  non-zero range does not fail but becomes the min or max, therefore, not useful
         for N in 1...92 {
            if let element = loadFromJSON(N) {
                if N == 1 { ranges.append( element.someFunction() ) }                 //  ranges[0] is an unused placeholder
//                if N == 1 { ranges.append(0...0) }                  //  inexplicably FAILS! @ ordinateMinimum/ordinateMaximum     if replacing above line
              ranges.append( element.someFunction() )
            }
            else { ranges.append(0...0) }                             //  some elements have no range data
        }
        ordinateMinimum = CGFloat(ranges.min(by: {$0 != 0...0 && $1 != 0...0 && $0.lowerBound < $1.lowerBound})!.lowerBound)
        ordinateMaximum = CGFloat(ranges.max(by: {$0 != 0...0 && $1 != 0...0 && $0.upperBound < $1.upperBound})!.upperBound)
        ordinateScale   = analysisView.frame.height/(ordinateMaximum - ordinateMinimum)

        for range in 1..<ranges.count {
            shapeLayer.addSublayer(CALayer())                       //  sublayer each abscissa range so that .strokeColor can be assigned to each
//            shapeLayer.frame = CGRect(x: 0, y: 0, width: analysisView.frame.width, height: analysisView.frame.height)   //  might be unneccessary

            let path               = CGMutablePath()                //  a new path for every sublayer, i.e., range that is displayed as line segment
            points.append(CGPoint(x: CGFloat(range)*abscissaScale, y: CGFloat(ranges[range].lowerBound)*ordinateScale))
            path.move(to: points.last! )
            points.append(CGPoint(x: CGFloat(range)*abscissaScale, y: CGFloat(ranges[range].upperBound)*ordinateScale))
            path.addLine(to: points.last! )
            path.closeSubpath()
            shapeLayer.path        = path
//            shapeLayer.strokeColor = CGColor.white
            let r:CGFloat = 1.0/CGFloat(range)
            let g:CGFloat = 0.3/CGFloat(range)
            let b:CGFloat = 0.7/CGFloat(range)
//            print("range: \(range)\tr: \(r)\tg: \(g)\tb: \(b)")       //  just to verify 0...1 values
            shapeLayer.strokeColor = CGColor(srgbRed: r, green: g, blue: b, alpha: 1.0)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.wantsLayer         = true                                          //  one of these (view or analysisView) must be unneccessary
        view.frame              = CGRect(x: 0, y: 0, width: 840, height: 640)
        analysisView.wantsLayer = true
        analysisView.frame      = CGRect(x: 0, y: 0, width: 840, height: 640)
        genrateGraph()
    }
    
}
Answered by OOPer in 692494022

Your code contains something unclear: loadFromJSON, someFunction and the type having the method someFunction. And there is no sample data.

So, we readers cannot test it and hard to say something sure.


But some parts of your code are clearly bad to use CAShapeLayer.

You create only one CAShapeLayer with this line let shapeLayer = CAShapeLayer(), but shapeLayer is not added as sublayer to any other layer in your code.

And, if you want multiple shape layers having different color each, you need to create multiple shape layers.

As already written, your code has many missing parts, so I have written a simplified example.

Please try this:

import AppKit

class AnalysisViewController: NSViewController {
    
    @IBOutlet var analysisView: NSView!
    
    func genrateGraph() {
        for bar: (CGFloat, CGFloat, CGFloat, ClosedRange<CGFloat>) in [
            (1.0, 0.0, 0.0, 0...100),
            (0.0, 1.0, 0.0, 100...200),
            (1.0, 1.0, 0.0, 200...300),
            (0.0, 0.0, 1.0, 300...400),
            (1.0, 0.0, 1.0, 400...500),
            (0.0, 1.0, 1.0, 500...600),
            (1.0, 1.0, 1.0, 600...700),
        ] {
            let color = CGColor(srgbRed: bar.0, green: bar.1, blue: bar.2, alpha: 1.0)
            let x = bar.3.lowerBound
            let width = bar.3.upperBound - bar.3.lowerBound
            //
            //Create `CAShapeLayer` for each color
            let layer = CAShapeLayer()
            analysisView.layer?.addSublayer(layer)
            let height: CGFloat = 320
            let rect = CGRect(x: x, y: 0, width: width, height: height)
            print(rect)
            let path = CGMutablePath()
            path.addRect(rect)
            layer.path = path
            layer.fillColor = color
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.frame              = CGRect(x: 0, y: 0, width: 840, height: 640)
        analysisView.wantsLayer = true
        analysisView.frame      = CGRect(x: 0, y: 0, width: 840, height: 640)
        genrateGraph()
    }
    
}

If this code does show you a grey window, you need to check if you properly setup AnalysisViewController on your storyboard.


By the way,

  • To work with CAShapeLayer, using CGPath (including CGMutablePath) is the simpler way.
  • CGPath (or CGMutablePath) does not have a method stroke() and you have no need to call it. (In fact, you cannot!)

It's a bit tough to understand all your questions.

it doesn't draw into the window

What do you get exactly ?

  • Nothing ?
  • some drawing, partly outside ?

What am I missing?

It seems you never call

        path.stroke()

So no drawing. You need to add after defining shapeLayer.strokeColor:

        shapeLayer.strokeColor = CGColor(srgbRed: r, green: g, blue: b, alpha: 1.0)
        path.stroke() // <<-- ADD HERE

You also mention in code:

// ranges.append(0...0) // inexplicably FAILS! @ ordinateMinimum/ordinateMaximum if replaces "if N == 1" below

Where does this fail ?

  • ranges.append(0...0) does not fail
  • where do you compute ordinateMinimum/ordinateMaximum ?

Thanks for the reply Claude31.

 **What do you get exactly ?
 Nothing ?   
 some drawing, partly outside ?**

Just a grey window. No indication of drawing anywhere.

 **add path.stroke() after shapeLayer.strikeColor = ...**

When added I get the error: "Value of type 'CGMutablePath' has no member 'stroke'"

You also mention in code: // ranges.append(0...0) // inexplicably FAILS! @ ordinateMinimum/ordinateMaximum if replaces "if N == 1" below Where does this fail ? ranges.append(0...0) does not fail where do you compute ordinateMinimum/ordinateMaximum ?

When the following lines are executed, the result is ordinateMinimum = 0 AND ordinateMaximum = 0 which then results in ordinateScale = inf

ordinateMinimum = CGFloat(ranges.min(by: {$0 != 0...0 && $1 != 0...0 && $0.lowerBound < $1.lowerBound})!.lowerBound) ordinateMaximum = CGFloat(ranges.max(by: {$0 != 0...0 && $1 != 0...0 && $0.upperBound < $1.upperBound})!.upperBound) ordinateScale = analysisView.frame.height/(ordinateMaximum - ordinateMinimum)

If you use CGMutablePath, you have to draw in a context. See example here:

https://stackoverflow.com/questions/58658115/cgcontext-cgmutablepath-drawing-line-but-its-be-reversed

Why don't you use BezierPath instead ? It should be simpler.

For the second question:

        ordinateMinimum = CGFloat(ranges.min(by: {$0 != 0...0 && $1 != 0...0 && $0.lowerBound < $1.lowerBound})!.lowerBound)
        ordinateMaximum = CGFloat(ranges.max(by: {$0 != 0...0 && $1 != 0...0 && $0.upperBound < $1.upperBound})!.upperBound)
        ordinateScale   = analysisView.frame.height/(ordinateMaximum - ordinateMinimum)

It all depends on what you have in ranges. Did you test with a print of lower and upper bounds of all elements in range ?

You should also test if you read JSON correctly ; if not, you append only 0...0, which will cause the crash with zeroDiv

         for N in 1...92 {    // N should be lowercase n
            if let element = loadFromJSON(N) {
                print("I read element", N)
                if N == 1 { ranges.append( element.someFunction() ) }    //  ranges[0] is an unused placeholder
                ranges.append( element.someFunction() )    // So, for n == 1, you load twice ?
            }
            else { ranges.append(0...0) }                             //  some elements have no range data
        }
Accepted Answer

Your code contains something unclear: loadFromJSON, someFunction and the type having the method someFunction. And there is no sample data.

So, we readers cannot test it and hard to say something sure.


But some parts of your code are clearly bad to use CAShapeLayer.

You create only one CAShapeLayer with this line let shapeLayer = CAShapeLayer(), but shapeLayer is not added as sublayer to any other layer in your code.

And, if you want multiple shape layers having different color each, you need to create multiple shape layers.

As already written, your code has many missing parts, so I have written a simplified example.

Please try this:

import AppKit

class AnalysisViewController: NSViewController {
    
    @IBOutlet var analysisView: NSView!
    
    func genrateGraph() {
        for bar: (CGFloat, CGFloat, CGFloat, ClosedRange<CGFloat>) in [
            (1.0, 0.0, 0.0, 0...100),
            (0.0, 1.0, 0.0, 100...200),
            (1.0, 1.0, 0.0, 200...300),
            (0.0, 0.0, 1.0, 300...400),
            (1.0, 0.0, 1.0, 400...500),
            (0.0, 1.0, 1.0, 500...600),
            (1.0, 1.0, 1.0, 600...700),
        ] {
            let color = CGColor(srgbRed: bar.0, green: bar.1, blue: bar.2, alpha: 1.0)
            let x = bar.3.lowerBound
            let width = bar.3.upperBound - bar.3.lowerBound
            //
            //Create `CAShapeLayer` for each color
            let layer = CAShapeLayer()
            analysisView.layer?.addSublayer(layer)
            let height: CGFloat = 320
            let rect = CGRect(x: x, y: 0, width: width, height: height)
            print(rect)
            let path = CGMutablePath()
            path.addRect(rect)
            layer.path = path
            layer.fillColor = color
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.frame              = CGRect(x: 0, y: 0, width: 840, height: 640)
        analysisView.wantsLayer = true
        analysisView.frame      = CGRect(x: 0, y: 0, width: 840, height: 640)
        genrateGraph()
    }
    
}

If this code does show you a grey window, you need to check if you properly setup AnalysisViewController on your storyboard.


By the way,

  • To work with CAShapeLayer, using CGPath (including CGMutablePath) is the simpler way.
  • CGPath (or CGMutablePath) does not have a method stroke() and you have no need to call it. (In fact, you cannot!)

As for the second problem...

Try this on your Playground:

import UIKit

let ranges = [
    1...4
    ,0...0
    ,0...1
    ,3...5
    ,0...0
    ,2...4
    ,3...7
]

let ordinateMinimum = CGFloat(ranges.min(by: {$0 != 0...0 && $1 != 0...0 && $0.lowerBound < $1.lowerBound})!.lowerBound)
let ordinateMaximum = CGFloat(ranges.max(by: {$0 != 0...0 && $1 != 0...0 && $0.upperBound < $1.upperBound})!.upperBound)
print("ordinateMinimum: \(ordinateMinimum)\nordinateMaximum: \(ordinateMaximum)")

Now if you erase the first entry 1...4 so that 0...0 is the first entry, you will get a totally different and WRONG answer! Curious.

It seems the min and max functions abort the sieve if the first pass doesn't yield a value.

The closure passed min or max needs to be consistent in comparing orders.

In a consistent ordering, one (and only one) of the following gets true:

  • a < b
  • a == b
  • a > b

Your closure {$0 != 0...0 && $1 != 0...0 && $0.lowerBound < $1.lowerBound} may return false, for both yourClosure(a,b) and yourClosure(b,a) even when a != b.

Don't you think it is strange for a comparison function?

In the header doc of min or max, you can find this description:

The predicate must be a strict weak ordering over the elements. That is, for any elements a, b, and c, the following conditions must hold:

  • areInIncreasingOrder(a, a) is always false. (Irreflexivity)
  • If areInIncreasingOrder(a, b) and areInIncreasingOrder(b, c) are both true, then areInIncreasingOrder(a, c) is also true. (Transitive comparability)
  • Two elements are incomparable if neither is ordered before the other according to the predicate. If a and b are incomparable, and b and c are incomparable, then a and c are also incomparable. (Transitive incomparability)

It is not clear enough, but I guess you want to do something like this:

import UIKit

/*
let ranges = [
    //1...4,
    0...0,
    0...1,
    3...5,
    0...0,
    2...4,
    3...7,
]
 */
let ranges = [
    1...4,
    0...0,
    1...6,
    3...5,
    0...0,
    2...4,
    3...7,
]

let defaultMinValue = 0
let ordinateMinimum = CGFloat(
    ranges
        .filter{$0 != 0...0}
        .min(by: {$0.lowerBound < $1.lowerBound})?
        .lowerBound ?? defaultMinValue
)
let defaultMaxValue = Int.max
let ordinateMaximum = CGFloat(
    ranges
        .filter{$0 != 0...0}
        .max(by: {$0.upperBound < $1.upperBound})?
        .upperBound ?? defaultMaxValue
)
print("ordinateMinimum: \(ordinateMinimum)\nordinateMaximum: \(ordinateMaximum)")

One more. You should not include two or more topics in a single thread. For example, if you made a separate thread for this min, max issue, more readers who were suffering from the same issue would be able to find the thread more easily.

I understand about multiple issues. I'll try to keep them separate in the future. Thanks.

You looked into this pretty deeply, and it reminds me of a mathematicians proof. They can be more litigious than lawyers, and necessarily so.

I didn't submit my case well. I just find it curious that if you run the min/max code on ranges =[ 1...4 ,0...0 ,1...6 ,3...5 ,0...0 ,2...4 ,3...7 ] you get a min=1 and max=7, and that is what I expect when looking at the data.

But now swap the first and second entries so that: ranges = [ 0...0 ,1...4 ,1...6 ,3...5 ,0...0 ,2...4 ,3...7 ] the result is min=0 and max=0

It is the same data, just rearranged. The results are dramatically different. The reason for it still eludes me.

Filtering is a good way to go. Thanks.

Missing Fundamental something
 
 
Q